commit a9d0dfff4ad075d934e4cea3b9bb07e7fc22b5d6 Author: Cam Proudlock Date: Tue Jul 14 07:58:51 2026 -0400 Initial import: shopdb-flask multi-site asset platform Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..5f050a90 --- /dev/null +++ b/.env.example @@ -0,0 +1,88 @@ +# shopdb-flask environment template. +# +# Copy to .env and fill in the values. .env is gitignored. ProductionConfig +# refuses to boot when SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, or +# CORS_ORIGINS are missing or use the dev defaults. +# +# See the DEPLOY wiki page for the full per-site deployment runbook. + +# ---- Flask ---- +FLASK_APP=wsgi.py + +# Set to 'production' for live sites. Other valid values: 'development', +# 'testing'. Production triggers ProductionConfig.validate() at boot. +FLASK_ENV=production + +# ---- Required secrets (production refuses to boot without these) ---- + +# Generate strong random values, e.g.: +# python -c "import secrets; print(secrets.token_urlsafe(64))" +SECRET_KEY=change-this-to-a-secure-random-string +JWT_SECRET_KEY=change-this-to-another-secure-random-string + +# ---- Database (required) ---- + +# Format: mysql+pymysql://:@:/?charset=utf8mb4 +# In docker-compose, host is `db` (the service name). +# The ?charset=utf8mb4 keeps the connection on utf8mb4; create the database as +# utf8mb4 too (see the DEPLOY wiki page). Both must be utf8mb4 to match the schema. +DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8mb4 + +# ---- CORS (required, no wildcards in production) ---- + +# Comma-separated list of explicit origins permitted to call the API. +# Example for a single-host facility deploy: +# CORS_ORIGINS=https://shopdb.facility-a.example.com +# Wildcard '*' is rejected by ProductionConfig.validate(). +CORS_ORIGINS=http://localhost:5173 + +# ---- JWT lifecycle (optional, defaults shown) ---- +JWT_ACCESS_TOKEN_EXPIRES=3600 +JWT_REFRESH_TOKEN_EXPIRES=2592000 + +# ---- Logging (optional) ---- +LOG_LEVEL=INFO + +# ---- docker-compose only ---- +# These are read by docker-compose.yml; not used by the Flask app directly. +MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD +MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD +MYSQL_PORT=3306 +API_PORT=5001 + +# ---- Zabbix integration (optional, for printer supply monitoring) ---- +ZABBIX_URL= +ZABBIX_TOKEN= + +# ---- Per-plugin collector API keys (optional) ---- +# Per ADR-006, each plugin can accept external collector input at +# /api/collector/. The framework checks +# COLLECTOR_API_KEY_ first, then COLLECTOR_API_KEY as fallback. +# COLLECTOR_API_KEY= +# COLLECTOR_API_KEY_COMPUTERS= + +# ---- Employee directory database (optional, read-only) ---- +# Separate HR/employee lookup DB consumed by the notifications plugin and the +# public shopfloor kiosks. Leave unset if the feature is not used; there is no +# safe default for the password, so an unset password fails loud. +# EMPLOYEE_DB_HOST= +# EMPLOYEE_DB_USER= +# EMPLOYEE_DB_PASSWORD= +# EMPLOYEE_DB_NAME=wjf_employees + +# ---- cmmc_usb database (USB check-in/out) ---- +# Separate read-write MySQL DB used by the USB plugin to track device +# check-in/out, lockers, and the check-in/out log. Leave unset if the feature +# is not used; there is no safe default for the password, so an unset password +# fails loud. +# CMMC_USB_DB_HOST= +# CMMC_USB_DB_USER= +# CMMC_USB_DB_PASSWORD= +# CMMC_USB_DB_NAME=cmmc_usb + +# ---- Subpath deployment (optional) ---- +# Serve the app under a URL prefix instead of the server root, e.g. as an IIS +# Application at /ops under an existing site. The frontend must be rebuilt with +# the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the +# app owns its own site/port (the default). See the INSTALL-WINDOWS-IIS wiki page. +# MOUNT_PATH=/ops diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..91bd3db2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.env filter=git-crypt diff=git-crypt diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9592a83e --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Flask +instance/ +*.db +*.sqlite + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# Logs +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# Node/Frontend +node_modules/ +frontend/dist/ +*.local + +# Secrets and credentials (defensive) +.env +.env.* +!.env.example +!.env.*.example +*.pem +*.key +id_rsa +id_rsa.* +*.ppk +*.p12 +*.pfx +secrets.json +secrets.yaml +secrets.yml +*_secret +*_secrets +credentials.json +scripts/site_imports/wjf/idmap.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6997d139 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,476 @@ +# Changelog + +All notable changes to shopdb-flask are recorded here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The product version (`__version__`) and the plugin-contract version +(`__contract_version__`) are distinct series with independent bump rules; see +ADR-007 and ADR-002. + +## [Unreleased] + +### Changed + +- Asset detail pages (machines, PCs, printers, network devices, measuring + tools) now share one canonical card skeleton: Identity -> type-specific -> + status -> Location & Organization -> domain -> Custom Fields -> Warranty -> + Relationships -> Notes -> audit footer. The location card reads "Location & + Organization" on every page. The network device page was rebuilt into the + family (its "Asset Information" folded into Identity, "Record Info" converted + to the standard audit footer). Printer Notes moved out of mid-page to + second-to-last and the printer gained an audit footer. Template reordering + only; no data or API changes. + +### Fixed + +- List pages keep the current page (and search term) in the URL query, so + paging to page 9, opening an item, and hitting browser Back returns to page 9 + instead of resetting to page 1. Applies to all 18 list views via a shared + useListQuery composable; page 1 with no search stays a bare path. +- PC detail Installed Applications no longer 500s and silently disappears on + real PCs (ComputerInstalledApp had no to_dict); the section renders app + name, version, and description again. +- Employee detail skips its USB panels when the usb plugin is disabled (no + more 404 console noise). +- Shopfloor kiosk header text is readable (light on the dark navy header). + + +### Added + +- Collector-driven PC -> printer relationships. The computers collector schema + gained optional `defaultprinter` (string) and `printers` (array of strings) + fields carrying Win32_Printer identifiers. On ingest each identifier resolves + to a printer asset (by windows name / share / hostname / asset number-name or + a communications IP) and the PC is linked to it: the default via a + `defaultprinter` relationship, the rest via `connectedto`. The links render in + the shared Relationships card on both the PC and printer detail pages. The + sync is idempotent and archives collector-created links to printers no longer + reported (tagged `assetrelationships.label = 'collector:printers'`, so + manually-created links are never touched); unresolved identifiers become + response warnings, never failures. The collector response carries + `printerlinkcount` and a `printerlinks` list. See docs/COLLECTOR-INTEGRATION.md. +- Searchable custom fields. Each custom-field definition gained a `searchable` + flag (Settings > Custom Fields). When on, that field's stored values are + matched by global search and a hit routes to the owning asset's detail page. + The asset's `search__enabled` domain toggle still applies, and matches + dedupe against built-in-field asset hits so an asset appears once. Inactive or + non-searchable fields are never matched. +- Single-label sheet-position printing. The single asset-label page + (`/print/asset-label/:assettype/:id`) gained an Output control that toggles + between the standalone label (unchanged default) and placing that one label at + a chosen cell (1-6, via a 2x3 grid picker) of a ULINE 6-up sheet, leaving the + other five cells blank. This prints a single label onto the correct physical + spot of a partially-used sheet instead of wasting a fresh sheet, mirroring the + legacy shopdb behavior and complementing the batch page's start-cell offset. + The ULINE 6-up cell layout and dimensions are replicated from + `AssetLabelBatch.vue` (left untouched); encode resolution stays shared via + `assetLabel.js`. + +## [0.7.0] - 2026-07-12 + +### Added + +- Email sending. A stdlib-only mail service (`shopdb/utils/mailer.py`; + `smtplib`/`ssl`/`email`) reads the existing `email` SMTP settings + settings-first with an `SMTP_*` env fallback, sends multipart HTML+text, and + is a graceful no-op (logs a warning, returns False) when email is disabled or + the host is unset. The SMTP password is never logged. Three flows use it: + (1) New-user welcome + forced first-login password change. Admin-created users + (POST `/api/users`) are flagged `mustchangepassword` (new `users` column, + migration `7d23_user_mustchangepassword`, default false) and sent a + best-effort welcome email with the facility name, username, temporary + password, and sign-in link; the account is created even if mail fails + (response carries a `warning`). Login returns `mustchangepassword`; the + frontend forces the user through a new `/change-password` view (POST + `/api/auth/change-password`, jwt-guarded) before the app, and changing the + password clears the flag and resets lockout counters. A self-service "Change + password" entry is also available from the user menu. + (2) Test email. POST `/api/settings/test-email` (settings.edit) sends a probe + and surfaces any SMTP error with the password scrubbed; wires up the Email / + SMTP settings page "Send Test Email" button. + (3) On-demand alert/report delivery. POST `/api/reports/email` + (reports.export) mails `{subject, columns, rows}` as an HTML table to a + supplied recipient or the site `alert_recipients`; an "Email report" button on + the Warranty and Toner report pages posts the loaded rows. There is no + scheduler: automation is an external cron hitting the endpoint with a scoped + API token (PAT). Documented in `docs/CONFIG.md`. +- Shared asset label/code generator: a single `/print/asset-label//` + page (public, like the other `/print/*` routes) that any asset detail page + opens via a "Print Label" button (machines, computers, printers, network + devices, measuring tools). A no-print controls panel toggles the layout + (`card` badge vs `plain` code-only), the code type (QR vs CODE128 barcode), + and what the code encodes: the asset page link, asset number, serial number, + a per-type custom target template, or - for measuring tools by default - the + tool's inspection location code so every tool at one operation shares one + code (e.g. `0615`). QR codes reuse the shared logo-overlay renderer. New + `printing` settings seed and surface on the Printing & Labels settings page: + `qr_target_machine`, `qr_target_computer`, `qr_target_network_device`, + `qr_target_measuring_tool`; `label_default_style` (default `card`) and + `label_default_codetype` (default `qr`); and a per-asset-type default for what + the code encodes, `label_default_encodes_` (machines default to their + machine number, measuring tools to their inspection location code, the rest to + a page link), all overridable on the label page itself. When the chosen field + has no value (e.g. serial number on an asset with none), the label states so + instead of rendering an empty code. Asset payloads now carry a derived + `locationcode` (leading token of the resolved own/inherited location name). +- Batch label sheets: a "Print Labels" button on each asset list page opens + `/print/asset-label-batch/`, a multi-select sheet that lays the + chosen assets onto ULINE label pages (6-up 3 in x 3 in, or a dense 72-up + mini-label format), with a start-cell offset to reuse partial sheets. Shares + the same code-type and encode settings/defaults as the single label, so a + batch of measuring tools encodes each tool's inspection location code just + like the single label does. Restores the ULINE batch printing the legacy + shopdb had, generalized across all asset types. +- Support-team contact UX: the settings Support Teams page now manages each + team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus + Add/Edit/Delete) instead of an inline row expander, and the application + detail Support card renders Email (`mailto:`) and Microsoft Teams chat + (`teams.microsoft.com/l/chat`) action buttons for every contact that has an + SSO. Both link targets derive as `sso@` from a new `site` setting + `contact_email_domain` (default `geaerospace.com`; blank hides the buttons), + surfaced in Site & Facility settings under Naming & Patterns and read on the + frontend via `getContactEmailDomain()`. +- Plugin `get_permissions` hook (contract 0.10.0) so a plugin declares the RBAC + permissions its own routes enforce, instead of core accumulating every + plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The + core catalog (`Permission.CORE_PERMISSIONS`) now holds only genuinely core + sets (assets, applications, reports, settings, users, audit, apitokens, + collector); the 36 permissions for machines, computers, printers, network, + knowledgebase, notifications, usb, warranty, and measuringtools moved into + each owning plugin's hook. New core helper `full_permission_catalog()` merges + core plus every ENABLED plugin's permissions and backs all three consumers: + `flask seed permissions`, the role grid (`GET /api/users/permissions`), and + API-token scope validation (`ApiToken.unknown_scope_names`). Plugin install + and enable seed the plugin's own permissions idempotently. A disabled plugin + drops out of the catalog (no new scope grants or role assignments), but its + existing `Permission` rows and role links persist so current roles keep + working. Docs: `docs/PLUGIN-HOOKS.md` new section, `docs/PLUGIN-GUIDE.md` + permissions walkthrough rewritten to the hook, `docs/PLUGIN-QUICKSTART.md` + hooks table row. +- Personal API tokens (PATs) so scripts and integrations authenticate without + the hourly-expiring login JWT (immediate consumer: long legacy-import runs + that die when the JWT expires mid-run). New core `apitokens` table + migration + `7d21_apitokens` (stores only the sha256 hash of each secret; the full secret + `shopdb_pat_<40 hex>` is shown ONCE at creation). New core blueprint + `/api/apitokens` (list own / admin `?all=true`; create; rename or deactivate; + revoke). A `Bearer shopdb_pat_...` header is recognized before any JWT decode + by a before_request shim that mints a request-scoped JWT for the token's + owner, so the entire existing auth+authz stack (jwt_required, + require_permission, require_role, import mode, current_user) authenticates the + PAT as its owner with zero decorator changes; an invalid, revoked, or expired + PAT gets a clean 401. `lastusedat` is stamped on use (throttled to at most one + write per 60s). Any authenticated user manages their own tokens; admins may + list or revoke anyone's. New Settings > API Tokens page (`ApiTokensList.vue`) + with a create modal that reveals the secret once (copy button) and an admin + All Tokens section. Docs: `docs/IMPORT-API.md` and `docs/CONFIG.md` updated to + recommend a PAT for imports. Core feature; no plugin-contract change. +- Optional permission scopes on personal API tokens. A token MAY carry a scopes + list (permission names, migration `7d22_apitokens_scopes` adds the nullable + `apitokens.scopes` JSON column); NULL keeps the original behavior (acts as its + owner). A scoped token grants ONLY the listed permissions, intersected with + what the owner actually holds at use time, and SUSPENDS the admin-role bypass, + so a scoped token minted by an admin is genuinely limited: it is denied on + role-gated (`require_role`) endpoints and gets no import mode. The shim mints + the request JWT with a `patscopes` claim that `require_permission`, + `require_role`, and `import_mode_active` read; normal login JWTs carry no such + claim and are unaffected (zero regression). Scopes are validated at write time + against the token OWNER's permissions (the scope ceiling - a token can never + grant more than its owner holds; when an admin edits another user's token the + ceiling is that owner's permissions), rejecting unknown or unheld names 400. + Minting/managing tokens now requires the new `apitokens.create` permission + (category `apitokens`; admins hold it by default, grantable via the roles UI) + rather than being open to any authenticated user. The Settings > API Tokens + create/edit modals gain a "Restrict permissions" section (a category-grouped + checkbox grid limited to the permissions the creator holds) and the token + lists show a full-access / N-permissions access chip. +- Managed collector service tokens: the collector ingest API + (`/api/collector/` + the legacy `/pc` `/apps` `/heartbeat` `/bulk` + `/status` endpoints) now ALSO accepts a managed API token scoped to the new + `collector.ingest` permission (category `collector`), on top of the existing + `COLLECTOR_API_KEY[_]` env keys (which stay supported as a + bootstrap/legacy fallback - nothing breaks). The token may be presented in + `X-API-Key` (as GE-Enforce sends today) OR as an `Authorization: Bearer` + token; both transports validate the PAT the same way the login shim does + (hash lookup, active, unexpired, active owner) via a shared + `resolve_api_token` helper refactored out of `apitoken_auth.py`, require + `collector.ingest` in the token's scope list AND that the owner holds it, and + stamp `lastusedat` (same 60s throttle). A token scoped to ONLY + `collector.ingest` is a collector service token: it authorizes the collector + API and NOTHING else - the existing scoped-token machinery denies it on every + permission- and role-gated route and on import mode, so a leaked collector + token cannot touch the regular API. Recommended flow (documented): an admin + mints the scoped token (the scope suspends the admin bypass, containing it); + rotate by minting a new one, deploying via `site-config.json`, watching + `lastusedat`, then revoking the old. The Settings > API Tokens create modal + gains a "Collector service token" quick-preset (pre-selects only + `collector.ingest`). Docs: `docs/COLLECTOR-INTEGRATION.md` (new "Managed + collector tokens" section) and `docs/CONFIG.md`. Core feature; no + plugin-contract change. +- Vendor-model photos on asset detail heroes: computers and printers now + surface the linked model's `imageurl` in their extension payloads (the + field machines already exposed), and the machine, PC, printer, network + device, and measuring tool detail pages render the photo in the hero card + when present (hidden cleanly when absent). Network devices and measuring + tools have no model link yet, so their heroes stay photo-less until one + is added. +- Dualpath "single machine" site toggle (`dualpath_single_machine`, default + on). A Dualpath relationship pair is one physical dual-bay machine (single + controller, bay-selector switch); when on, the machines list, dashboard and + machines-by-type counts, and the floor map collapse each pair to one entry + (the lower natural-sort assetnumber is PRIMARY; the SECONDARY bay is hidden) + and show a combined `2007 / 2008` label. The data model is unchanged (both + bay records always exist); detail pages stay per-bay and always show a + sibling-bay banner regardless of the toggle. Contract surface (plugin + contract bumped 0.8.0 -> 0.9.0, additive): new `shopdb.api` helpers + `resolve_dualpath_pairs` and `dualpath_single_machine_enabled`, consumed by + the machines plugin to collapse pairs contract-purely. +- Relationship propagation, wired and data-driven: relationship types + declare propagation-through pairs (relationshiptypepropagations M:N, + replacing the never-consumed single column); creating a controls link on + one Dualpath bay auto-creates it on the partner bay, and + `flask relationships propagate` backfills existing data. +- Employee photos, mode-aware: self-hosted directory employees support + upload/replace/delete (admin), served publicly for kiosk cards; external + directory mode passes the HR-supplied picture URL through read-only. One + resolver feeds the shopfloor recognition/recert cards and the employee + detail hero in either mode. +- Vendor-model photo management. New admin-gated core endpoints + `POST /api/models//image` (multipart `file`, png/jpg/jpeg/gif/webp/svg, + one image per model, replace semantics) and + `DELETE /api/models//image`, plus the public + `GET /api/models/image/` serve route. Uploads land in + `instance/modelimages/` (survives upgrades, backed up with the rest of + `instance/`) and set `models.imageurl` to the served URL; the manual Image URL + field still accepts external URLs and the shipped `/images/models/*` assets + (upload is additive). Delete only removes files we own under the instance dir. + The Models settings page grows a thumbnail, Upload/Replace, and Remove + controls in the edit modal. Asset hero images (e.g. the machine badge) read + `imageurl` unchanged, so uploaded photos render with no consumer changes. + +- Application support teams with contacts, replacing the legacy + supportteams/appowners pair. New core `supportteamcontacts` table (multiple + named contacts per team, ordered by `sortorder`); `supportteams` keeps + `teamname` (now unique) and `teamurl` (a ServiceNow group deep link) and + sheds the single-owner `appownerid` FK. New core blueprint at + `/api/supportteams` (team + nested contact CRUD, admin-gated; `?teamname` + exact-match lookup for import; delete a team 409s while any application still + references it). Migration `7d18_supportteamcontacts` migrates each legacy + team's app owner into one contact. Application payloads now flatten + `supportteamname`, `teamurl`, and the team's active `contacts`; a Support + card on the application detail page and a new `settings/supportteams` + management page render them. + +- Import mode: a complete, idempotent HTTP migration surface so a migration script + can import the classic ASP shopdb through the API alone (no direct DB writes). + - Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new + `shopdb.api` helpers `apply_import_timestamps`, `import_mode_active`, + `parse_import_datetime` in `shopdb/utils/import_mode.py`. When the caller is + an admin AND sends header `X-Import-Mode: true`, create/update endpoints + accept optional `createddate` / `modifieddate` (ISO or legacy + `YYYY-MM-DD HH:MM:SS`, naive-UTC) and preserve them instead of stamping now. + Non-admin or missing header: the fields are ignored exactly as before. + Wired into every timestamped import target: assets (all five type plugins), + vendors, models, modeltypes, businessunits, locations, operating systems, + applications, knowledge base, USB devices, and asset relationships. + - Natural-key exact-match lookup filters for the documented + lookup-then-upsert idempotency recipe: `assetnumber` on all five asset + plugin list endpoints; `vendor`, `modelnumber`, `modeltype`, + `businessunit`, `locationname`, `osname`/`osversion`, `appname`, + knowledge base `linkurl`/`shortdescription`, warranty `servicetag`/`vendor`, + and notification `ticketnumber`. + - Backdated event history: in import mode the selfhosted USB checkout/checkin + endpoints accept optional `checkouttime` / `checkintime` overrides so + migrated `usbcheckouts` rows keep their real event times. + - New operator manual `docs/IMPORT-API.md` grounded in the real `prodscratch` + legacy schema: order of operations, a full table-by-table mapping, honest + no-target list with dispositions, a worked idempotent Python importer, and + row-count parity checks. + +### Changed + +- System Settings is no longer one tabbed page. The inner tab bar is gone and + each section is its own routed settings page reached through the settings + rail: ServiceNow, Zabbix Supplies, Dell Warranty, and Collector PC Types + (the former Integrations dumping ground, now split three-plus ways), plus + Branding, Floor Map, Printing & Labels, Email / SMTP, Authentication, + Audit & Logging, Asset Identifiers, and Global Search. The rail regroups + these under Site & Facility, Integrations, Communication, Search & Identity, + and Access & Security. Shared load/save/upload plumbing moved into a + `useSystemSettings` composable so the pages stay thin. Old bookmarks keep + working: `/settings/system` and every `/settings/system?tab=` redirect + to the matching new page. + +### Fixed + +- Audit log: hovering a user's SSO now shows their full name (best-effort, + resolved from the employee directory in either mode). +- Refreshed the internal status docs to match the code (project active + state, CONTRACT-STABILITY.md and README plugin list at contract 0.10.0), + corrected the get_asset_panels endpoint path in the hook docstring, and + removed leftover debug console.log lines. +- Measuring tools wired into the remaining cross-cutting surfaces an audit + found them missing from: generic asset serialization (typedata + pluginid, + which also fixes relationship-card links to tools), map subtype + filtering/coloring and the MapEditor filter, dashboard totals, warranty + asset links (via a new by-asset detail route), and the two ADR-010 hook + declarations (presentation route token corrected; the calibration + map-overlay endpoint now actually exists). The login avatar also resolves + through the employee-photo helper, so self-hosted uploads show. +- Measuring tools are now wired into the asset-identifier matrix and global + search. The Settings identifier matrix gains a Measuring Tool column and the + gauge-lab and maintenance reference inputs/rows on the measuring-tool form + and detail pages honor those per-type toggles (a maintenance-reference field + was added, matching the other asset types). Global search gains a Measuring + Tools domain toggle and filter chip, routes measuring-tool hits to + `/measuringtools/` (previously the generic `/assets/` fallback), and + matches on `gaugelabreference` so a gage-tag lookup resolves the tool. +- Site & Facility settings page renders booleans as toggles and the + directory-mode settings as dropdowns, with labels and help text for every + field (no more raw keys or type-true/false boxes). +- System Settings tabs follow the URL: clicking a settings-rail link that + only changes the ?tab= query (Branding, Floor Map) now switches the right + panel, tab clicks update the URL, and browser back/forward restore tabs. +- Following a relationship link between two assets of the same type now loads + the destination page instead of stale content (router-view keyed on path; + query-only URL changes still avoid a remount). +- Asset relationships card no longer lists a symmetric peer twice. Relationship + types gain `relationshiptypes.isdirectional` (migration + `7d19_relationshiptype_directional`; seeded false for the connection-like + types Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet, + USB, WiFi, true for controls/Controlled By/Backup For/Master-Slave/partof/ + defaultprinter). The card now collapses every stored direction row of a + symmetric type into one direction-blind "Connected" entry per peer (deleting + it removes all collapsed rows), while directional types drop the + Outgoing/Incoming headers for inline `Type -> peer` / `<- Type from peer` + phrasing. The type CRUD and the per-asset relationships endpoint carry + `isdirectional`; the Relationship Types settings page gains a Directional + toggle. + +## [0.6.0] - 2026-07-11 + +### Added + +- measuringtools plugin (ADR-005): gage-lab instruments as Asset extensions + with type lookup (color-coded), calibration tracking (derived + overdue/due-soon/current status), calibration report, and full frontend. + Built as the framework exemplar; docs/PLUGIN-GUIDE.md walks through its + construction step by step as the plugin-system tutorial. +- CSV export on the Warranty and Toner report pages; per-report filter + controls (business unit, asset type, location, application, limit) on the + inline core reports; report open-state is URL-backed and deep-linkable. +- Per-plugin Alembic migration chains (ADR-008): every bundled plugin now + carries its own chain with a stamp-only anchor; new plugin schema changes + land in `plugins//migrations/`, never the core chain. Deploys run + `flask plugin upgrade-all` after `flask db upgrade`. +- Frontend plugin route gating (ADR-009): a disabled backend plugin's pages + redirect to the dashboard; new public `GET /api/plugins/enabled`. +- `get_reports()` plugin hook (plugin contract 0.5.0 -> 0.6.0): plugins + contribute their own report cards; warranty and toner cards moved off the + hardcoded frontend list. +- Reports hub grouped by category with a search filter. +- Configurable QR label targets: `qr_target_printer` / `qr_target_usb` + settings (blank = the asset's own page, else a URL template with + placeholders) and a `usb_label_style` barcode/QR toggle for USB + mini-labels. New Settings > Printing & Labels section. +- Site palette theming: optional `brand_primary_dark_color`, + `brand_accent_color`, `brand_sidebar_color` settings applied at bootstrap. +- Collector integration guide rewrite: header-only auth reference and a + paste-ready GE-Enforce PowerShell reporting function. + +### Changed + +- Equipment -> machines rename (backend). The equipment plugin is now the + machines plugin: `/api/equipment` -> `/api/machines`, tables + `equipment`/`equipmenttypes` -> `machines`/`machinetypes` (columns + `equipmentid` -> `machineid`, `equipmenttypeid` -> `machinetypeid`, + `equipmenttype` -> `machinetype`), permissions `equipment.*` -> + `machines.*`, assettype value `equipment` -> `machine`. The legacy core + `machinetypes` lookup (it types the vendor MODELS catalog, not machine + instances) is renamed to `modeltypes` (`/api/machinetypes` -> + `/api/modeltypes`, `models.machinetypeid` -> `models.modeltypeid`) to + free the name. Data flips migrate assettypes, auditlog entitytype, + settings keys (`identifier_*_equipment_enabled` -> + `identifier_*_machine_enabled`, `search_equipment_enabled` -> + `search_machine_enabled`), and permission rows in place; plugins.json + registry entries carry over automatically. Upgrade: run + `flask db upgrade` then `flask plugin upgrade-all`. + +- Inter (variable) replaces Roboto, bundled locally - no Google Fonts + fetch, so air-gapped installs render correctly. Tables use tabular + numerals. +- ServiceNow defaults point at the current geaerospaceqa.service-now.com + global search (search, incident, and change links). + +### Fixed + +- USB frontend remapped to the actual API shape (`device_id` / + `device_desc`): device list, detail, form, label batch, and the employee + profile's checked-out/history panels were all reading dead legacy fields. +- External-mode `GET /api/usb/checkouts/active` now honors the `badge` + filter. +- Warranties list page no longer demands login (matches every other list + page; reads were already public). +- Removed the dead legacy Warranty Status report (always-zero buckets from + a retired column); the warranty plugin's report is the real one. +- Pruned dead usbApi client methods that had no backend routes. + +## [0.5.0] - 2026-07-10 + +First release cut with a version, tag, changelog, and CI. Focused on +letting other GE Aerospace sites stand up their own self-hosted instance +(single-tenant per ADR-004). + +### Added + +- First-run setup wizard (`/setup`): creates the initial superadmin + in-app, configures each plugin (create tables here vs connect your own + database), uploads light/dark floor-map blueprints, and seeds starter + reference data. +- Self-hosted employee directory and USB plugins: in-app management plus + CSV import, no external database required. Both ship default-disabled + with an enable-time provisioning note. +- Dell warranty plugin: real Dell provider, bulk warranty sync, + add-warranty from asset pages, PC hero warranty badge, disk-cached Dell + API token. +- Custom fields, and a two-pane settings shell with tabbed, searchable + System Settings and Settings index pages. +- Dashboard defaults (visitor-IP to business-unit mapping) for kiosk + displays; printer installer endpoint (data plus floor-map positions). +- Global toast notifications replacing `alert()` calls. +- Multi-stage Docker build that compiles the Vue frontend and ships + `frontend/dist`, which Flask serves. +- Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides; + reconciled README, DEPLOY, status docs, and ROADMAP. +- ADR-007 (product versioning and releases), CHANGELOG, and best-effort + CI (backend tests, naming/style gate, frontend build). + +### Changed + +- Plugin contract (`__contract_version__`) settled at 0.5.0: full plugin + import surface exposed via `shopdb.api`, dead search hook removed, and + the dashboard-widgets hook wired to a real consumer. +- Role-based access control now enforced on write routes, including + admin-only guards on dashboard-defaults writes. +- Branding, ServiceNow integration, employee-ID pattern, printer + hostname template, and floor-plan blueprints are settings-driven and + per-site configurable, with GE defaults preserved as shipped fallbacks + (branding and floor-plan configurability landed in this release; some + consumer wiring continues under Unreleased). + +### Security + +- Dashboard-defaults writes now require admin authorization instead of + any authenticated user. +- Collector error responses no longer leak exception detail; failures are + logged server-side with generic client-facing messages. +- Login rate limiting added (IP-based fixed window) on top of the + existing account lockout. + +### BREAKING + +- Collector API key must now be sent in the `X-API-Key` header. The + api-key-in-querystring fallback has been removed. Update any collector + integration that passed the key as a query parameter. See + `docs/COLLECTOR-INTEGRATION.md`. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..db0f47aa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,162 @@ +# Contributing to ShopDB Flask + +## Coding Standards + +### Naming principles + +1. Spell it out. If a non-technical user looking at a report would not understand a column at a glance, rename it. +2. No project-specific shorthand. A name either uses a word in full, or uses an acronym from the allowed list below. +3. Consistency beats cleverness. Once a name is committed and used in code, do not rename it for marginal improvement. +4. When unsure, ask: "would a new shop-floor IT person understand this?" + +### Database + +- **Table names**: lowercase, concatenated, plural. No dashes, no underscores. + - Good: `machines`, `pctypes`, `networkdevices`, `businessunits`, `auditlogs` + - Bad: `machine_types`, `network-devices`, `BusinessUnits` +- **Column names**: lowercase, concatenated, singular. No dashes, no underscores. + - Good: `machineid`, `machinenumber`, `lastzabbixsync`, `isactive` + - Bad: `machine_id`, `last_zabbix_sync`, `IsActive` +- **Foreign keys**: referenced table name (singular) + `id`. + - Good: `locationid` references `locations`, `vendorid` references `vendors` +- **Booleans**: prefix with `is` or `has`. + - Good: `isactive`, `isshopfloor`, `hasprinter` +- **Index names**: `idx__`. Underscores allowed here only because indexes are infrastructure, not data. + - Good: `idx_machines_locationid` + +### Allowed acronyms (closed list, expand by PR) + +- **Universal**: `id`, `url`, `api`, `http`, `https`, `json`, `jwt`, `sql`, `os`, `ip`, `dns`, `csv`, `pdf`, `cors`, `ttl`, `uuid`, `html`, `css`, `orm` +- **Domain (shop floor / IT infra)**: `cmm`, `cnc`, `pc`, `usb`, `vnc`, `winrm`, `ssh`, `ssl`, `tls`, `tcp`, `udp`, `smtp`, `ldap`, `vlan`, `sso`, `dnc`, `focas`, `clm`, `mtconnect` + +Any acronym not on these lists must be spelled out. Proposing a new acronym is a separate PR that updates this list. + +**Third-party imports are exempt.** Library and stdlib module paths cannot be renamed. `from sqlalchemy.orm import ...`, `import importlib.util`, `from werkzeug.utils import ...` are all fine even if they contain words that would otherwise be banned. + +### Banned shorthand + +`cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`, and any other domain-specific shortening. Spell it out: `config`, `context`, `manager`, `request`, `response`, `environment`, `utilities`. Note: `db` as a standalone variable name is banned, but `db` as a prefix in `database` or as part of project name `shopdb` is fine. + +**Suffix exception.** Banned shorthand applies to standalone identifiers. As a suffix where the prefix establishes meaning, it is acceptable: + +- `printers_bp`, `auth_bp`, `network_bp` are fine. The prefix names what blueprint, the `_bp` suffix is conventional for Flask blueprints. +- `request_obj` is fine when disambiguating from imported `request`. +- `bp` alone as a variable name is not fine. Always pair with a meaningful prefix. + +### Code + +#### Python + +- **Variables and functions holding a database value**: match the column name exactly. No conversion to snake_case. + - DB column `machineid` -> Python variable `machineid`, attribute `Machine.machineid`, dict key `{"machineid": 1}` + - DB column `lastzabbixsync` -> Python variable `lastzabbixsync` +- **Pure code variables and functions** (loop counters, helpers, anything that does not mirror a DB field): snake_case per PEP 8. + - Good: `loop_count`, `current_user`, `validate_input()` +- **Classes**: PascalCase. Spell out fully. + - Good: `PrinterData`, `AssetType`, `NetworkDevice` +- **Modules / file names**: lowercase. Underscores allowed where it improves readability (PEP 8 permits this for modules). + - Good: `employee_service.py`, `asset_routes.py` + +#### JavaScript / Vue + +- **Variables**: camelCase per JS convention. + - Good: `currentUser`, `isLoading` +- **Variables holding API field values**: match the API field name exactly. Do NOT convert to camelCase. + - API returns `{"machineid": 1}` -> JS `response.machineid`, NOT `response.machineId` + - API returns `{"lastzabbixsync": "..."}` -> JS `device.lastzabbixsync` +- **Components**: PascalCase. Spell out fully. + - Good: `AssetDetail.vue`, `MachineForm.vue`, `PrinterList.vue` +- **CSS classes**: lowercase with dashes. + - Good: `asset-detail`, `machine-form` + +#### API + +- **Endpoints**: lowercase, plural nouns. No underscores or dashes. + - Good: `/api/machines`, `/api/networkdevices`, `/api/businessunits` +- **Query parameters**: lowercase, concatenated. Match column names where applicable. + - Good: `?locationid=5`, `?isactive=true` +- **Response keys**: match database column names exactly. + - Good: `{"machineid": 1, "lastzabbixsync": "2026-01-12T10:00:00Z"}` + +### Examples + +```python +# Good - DB-mirrored fields keep column names; pure code uses snake_case +class PrinterData(db.Model): + __tablename__ = 'printerdata' + + machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid')) + lastzabbixsync = db.Column(db.DateTime) + isnetworkprinter = db.Column(db.Boolean) + + +def list_printers_by_location(locationid: int) -> list[dict]: + printers = PrinterData.query.filter_by(locationid=locationid).all() + result_count = len(printers) + return [{"machineid": p.machineid, "isnetworkprinter": p.isnetworkprinter} for p in printers] +``` + +```python +# Bad - PEP 8-style underscores on DB fields, banned shorthand, project acronym not on allowed list +class PrinterData(db.Model): + __tablename__ = 'printer_data' + + machine_id = db.Column(db.Integer) + last_zabbix_sync = db.Column(db.DateTime) + is_net_prn = db.Column(db.Boolean) + + +def list_prns_by_loc(loc_id): + bp_ctx = get_context() + cfg = load_cfg() + return PrinterData.query.filter_by(loc_id=loc_id).all() +``` + +### Style policy (non-naming) + +- No emojis in code, comments, documentation, string literals, or UI. +- No em-dashes (U+2014), en-dashes (U+2013), Unicode arrows, or smart quotes anywhere. Plain ASCII only. +- Comments default to none. Add one only when the WHY is non-obvious. Keep comments terse (lite caveman style is fine for inline `#` and `//` comments). Docstrings stay normal English. +- Dark theme is default. Keep UI functional and professional. + +### File structure + +- Core models: `shopdb/core/models/` +- Core API routes: `shopdb/core/api/` +- Core services: `shopdb/core/services/` +- Plugin code: `plugins//{models,api,services,schemas}/` + +### Plugin development + +When creating a new plugin: + +1. Create directory `plugins//`. Use the scaffold (`flask plugin new `) when available. +2. Include `manifest.json` with metadata. This is the single source of truth for plugin name, version, dependencies, api_prefix. +3. Extend `BasePlugin` class. +4. Follow naming conventions above. +5. Run `flask plugin install ` to install. + +See the PLUGIN-QUICKSTART wiki page (when published) for the 30-minute walkthrough. + +## Testing + +Run tests with: + +```bash +pytest tests/ +``` + +Tests are required for: +- New routes (smoke + happy path + 1-2 edge cases) +- New plugin hooks (contract test) +- Schema migrations (before/after data integrity) + +## Code review checklist + +- [ ] No underscores in table or column names +- [ ] No banned shorthand or unapproved acronyms in any new identifier +- [ ] DB-mirrored Python and JS variables match column names exactly +- [ ] API response keys match column names exactly +- [ ] No em-dashes, en-dashes, smart quotes, Unicode arrows, or emojis (run `grep -rP '[^\x00-\x7F]' --include='*.py' --include='*.vue' --include='*.js' .`) +- [ ] Plugin follows `BasePlugin` interface and contract version +- [ ] Tests included for new functionality diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..9450c595 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +# shopdb-flask single-site container. +# +# One image, one site. Per ADR-004, each adopting facility runs its own +# stack with its own DB, secrets, and enabled-plugin list. This image +# bundles all eleven core plugins (computers, employees, knowledgebase, +# machines, measuringtools, network, notifications, printers, slides, usb, +# warranty); +# install them at runtime with `flask plugin install `. +# +# The frontend is built in a first stage and its dist output is copied into +# the final image so Flask can serve the SPA (register_frontend_routes in +# shopdb/__init__.py resolves /frontend/dist). +# +# Build: +# docker build -t shopdb-flask . +# Run (with .env): +# docker run --env-file .env -p 5001:5001 shopdb-flask + +# ---- Stage 1: build the Vue frontend ---- +FROM node:20-slim AS frontendbuild + +WORKDIR /build + +# Copy only the manifests first so `npm ci` caches on dependency changes. +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build +# Output lands in /build/dist (Vite default), copied into the final stage below. + +# ---- Stage 2: Python application image ---- +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir gunicorn + +COPY shopdb/ ./shopdb/ +COPY plugins/ ./plugins/ +COPY migrations/ ./migrations/ +COPY scripts/ ./scripts/ +COPY wsgi.py ./ + +# Built SPA from stage 1. Flask serves it via register_frontend_routes. +COPY --from=frontendbuild /build/dist ./frontend/dist + +RUN useradd --create-home --shell /bin/bash shopdb \ + && chown -R shopdb:shopdb /app +USER shopdb + +EXPOSE 5001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl --fail --silent http://localhost:5001/api/auth/login -X POST \ + -H "Content-Type: application/json" -d '{}' \ + | grep -q "VALIDATION_ERROR" || exit 1 + +CMD ["gunicorn", \ + "--bind", "0.0.0.0:5001", \ + "--workers", "4", \ + "--timeout", "60", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "wsgi:app"] diff --git a/README.md b/README.md new file mode 100644 index 00000000..f56f740f --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# ShopDB Flask + +A modern rewrite of the classic ASP/VBScript ShopDB application using Flask (Python) and Vue 3. This application manages shop floor machines, PCs, printers, applications, and related infrastructure for manufacturing environments. + +## Overview + +ShopDB tracks and manages: +- **Machines** - CNC equipment, CMMs, inspection systems, etc. +- **PCs** - Shopfloor computers, engineering workstations +- **Printers** - Network printers with Zabbix supply integration +- **Network devices** - Switches, routers, and the subnet browser +- **Measuring tools** - Gage-lab instruments with calibration tracking +- **Applications** - Software deployed across the shop floor, with per-PC install tracking +- **Employees** - Directory, recognition and training notifications +- **Warranties** - Coverage records with Dell warranty lookups +- **USB devices** - CMMC check-in/out tracking +- **Knowledge Base** - Documentation and troubleshooting guides +- **GE-Enforce manifests** - Imaging/software manifest editing and fleet compliance + +## Tech Stack + +**Backend:** +- Python 3.12 with Flask +- SQLAlchemy ORM +- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page) +- JWT authentication +- Plugin architecture for extensibility + +**Frontend:** +- Vue 3 with Composition API +- Vue Router for navigation +- Pinia for state management +- Vite build system + +## Project Structure + +``` +shopdb-flask/ +├── shopdb/ # Flask application +│ ├── core/ +│ │ ├── api/ # REST API endpoints +│ │ ├── models/ # SQLAlchemy models +│ │ ├── schemas/ # Validation schemas +│ │ └── services/ # Business logic +│ ├── plugins/ # Plugin system +│ └── utils/ # Shared utilities +├── frontend/ # Vue 3 application +│ ├── src/ +│ │ ├── api/ # API client +│ │ ├── components/ # Reusable components +│ │ ├── views/ # Page components +│ │ ├── router/ # Route definitions +│ │ └── stores/ # Pinia stores +│ └── public/ # Static assets +├── plugins/ # Bundled and external plugins +├── migrations/ # Alembic migration chain (flask db upgrade) +├── scripts/ # Import and utility scripts +└── tests/ # Test suite +``` + +## Naming Conventions + +To maintain consistency with the legacy ShopDB database and codebase, the following naming standards apply: + +### Database + +- **Table names:** Lowercase, single word, no underscores or dashes + - Examples: `assets`, `computers`, `printers`, `businessunits` +- **Column names:** Lowercase, single word, no underscores or dashes + - Examples: `assetid`, `assetnumber`, `hostname`, `isactive`, `createddate` +- **Foreign keys:** Referenced table name + `id` + - Examples: `locationid`, `vendorid`, `modelnumberid`, `computertypeid` +- **Boolean columns:** Prefixed with `is` or `has` + - Examples: `isactive`, `isshopfloor`, `iscolor`, `isdhcp`, `islicenced` + +### Code + +- **Python variables:** Follow database naming where applicable (lowercase, no underscores for model fields) +- **JavaScript variables:** camelCase for local variables, but match API field names from backend +- **Vue components:** PascalCase for component names +- **CSS classes:** Lowercase with dashes for multi-word classes + +### API + +- **Endpoints:** Lowercase, plural nouns + - Examples: `/api/machines`, `/api/computers`, `/api/locations` +- **Query parameters:** Lowercase, single word + - Examples: `?locationid=5`, `?isactive=true`, `?assettype=computer` + +## Style Guidelines + +- No emojis in code, comments, documentation, or UI +- Keep UI functional and professional +- Dark theme is the default +- Consistent table layouts across all list views + +## Setup + +### Prerequisites + +- Python 3.12 +- Node.js 18+ +- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page) + +ShopDB Flask uses MySQL as the canonical database. SQLite is used only for the +test suite (`TestingConfig` in `shopdb/config.py` points at an in-memory +SQLite). Do not run dev or production against SQLite. + +### Distribution + +The application is distributed through the internal GE Aerospace git server. Clone +it from there; there is no public package or image registry. + +### Fast path (Docker) + +The Docker image builds the Vue frontend and serves it from the API container, +so a container deploy needs no separate Node build step. + +```bash +cp .env.example .env +# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS, +# and the MYSQL_* passwords (see the CONFIG wiki page for every variable). + +docker compose up -d --build + +# Create the schema and seed the platform data (idempotent, safe to re-run): +docker compose exec api flask db upgrade +docker compose exec api flask seed permissions +docker compose exec api flask seed settings +docker compose exec api flask seed reference-data +``` + +Then browse to the site and complete the first-run setup wizard at `/setup` +(it creates the first admin account and captures site identity). To create the +admin headlessly instead of using the wizard: + +```bash +docker compose exec api flask seed admin --username admin --email admin@facility.example.com +``` + +### Manual path (venv + Node) + +```bash +# Backend +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +# Edit .env with your database credentials and secrets. + +flask db upgrade +flask seed permissions +flask seed settings +flask seed reference-data +flask run + +# Frontend (separate terminal) +cd frontend +npm install +npm run dev # dev server on :5173 +npm run build # production build into frontend/dist (served by Flask) +``` + +Complete first-run setup at `/setup`, or run `flask seed admin` for a headless +admin account. + +To import a site's legacy data, use the HTTP import surface: an admin API +token plus the IMPORT-API page in the project wiki drive the whole migration +through documented endpoints (`X-Import-Mode` preserves original timestamps). +`scripts/site_imports/wjf/` is the West Jefferson reference loader. + +For the full per-site deployment runbook see the DEPLOY page in the project wiki; +for every environment variable and Setting key see the CONFIG page in the project wiki. + +## Configuration + +Environment variables (`.env`): + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | MySQL connection string | +| `SECRET_KEY` | Flask secret key | +| `JWT_SECRET_KEY` | JWT signing key | +| `JWT_ACCESS_TOKEN_EXPIRES` | Access token TTL (seconds) | +| `LOG_LEVEL` | Logging verbosity | + +## API Documentation + +The REST API follows standard conventions: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/machines` | List machines (filterable by type) | +| GET | `/api/machines/:id` | Get machine details | +| POST | `/api/machines` | Create machine | +| PUT | `/api/machines/:id` | Update machine | +| DELETE | `/api/machines/:id` | Soft delete machine | + +Each asset plugin exposes the same CRUD pattern on its own prefix +(`/api/computers`, `/api/printers`, `/api/network`, `/api/measuringtools`), +and cross-cutting asset endpoints live under `/api/assets`. + +Query parameters for list endpoints: +- `page` - Page number (default: 1) +- `perpage` - Items per page +- `sort` - Sort field +- `dir` - Sort direction (asc/desc) +- `search` - Search term +- `assettype` - Filter by asset type (computer, printer, machine, networkdevice, measuringtool) + +## Plugin System + +ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines. + +The image bundles twelve plugins; only the ones a site installs are loaded: + +- **computers** - Shopfloor PCs and workstations, collector fleet ingest +- **employees** - Employee directory +- **geenforce** - GE-Enforce imaging/software manifests and fleet compliance +- **machines** - CNC, CMM, and other shop-floor machines +- **measuringtools** - Gage-lab instruments with calibration tracking +- **knowledgebase** - Documentation and troubleshooting guides +- **network** - Network devices and subnets +- **notifications** - Shopfloor notifications and recognition feed +- **printers** - Extended printer management with Zabbix integration +- **slides** - TV/kiosk slideshows +- **usb** - CMMC USB check-in/out tracking +- **warranty** - Warranty records with Dell lookups + +## Legacy Migration + +This project replicates functionality from the classic ASP/VBScript ShopDB site. Key mappings: + +| Legacy | Modern | +|--------|--------| +| ASP/VBScript | Flask/Python | +| Classic ADO | SQLAlchemy | +| Server-side HTML | Vue 3 SPA | +| Session auth | JWT tokens | + +## License + +Internal use only. diff --git a/deploy/windows/web.config b/deploy/windows/web.config new file mode 100644 index 00000000..fe712b0d --- /dev/null +++ b/deploy/windows/web.config @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..8a7d62d3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +# shopdb-flask single-site docker-compose template. +# +# Per ADR-004, each adopting facility runs its own stack. This template +# brings up MySQL + the API container and exposes the API on port 5001. +# The Vue frontend is served separately by the API in production builds +# (see register_frontend_routes in shopdb/__init__.py); for dev, run +# `npm run dev` in frontend/ on a separate port. +# +# Usage: +# cp .env.example .env +# # edit .env with site-specific secrets and origins +# docker compose up -d +# +# Refresh after pulling new code: +# docker compose build api +# docker compose up -d api + +services: + db: + image: mysql:8.0 + # utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the + # image default. Keeps every site's schema on the same charset/collation. + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} + volumes: + - db_data:/var/lib/mysql + # Bind MySQL to loopback only. The api container reaches db over the + # compose network regardless of this mapping; the published port is just + # for local admin tools (mysqldump, a client on the host). Exposing 3306 + # on all interfaces would put the database on the facility network. + ports: + - "127.0.0.1:${MYSQL_PORT:-3306}:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + + api: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + FLASK_ENV: production + DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4 + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set} + CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ZABBIX_URL: ${ZABBIX_URL:-} + ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} + ports: + - "${API_PORT:-5001}:5001" + volumes: + - ./plugins:/app/plugins:ro + +volumes: + db_data: diff --git a/frontend/DEVELOPMENT-STANDARDS.md b/frontend/DEVELOPMENT-STANDARDS.md new file mode 100644 index 00000000..700750f8 --- /dev/null +++ b/frontend/DEVELOPMENT-STANDARDS.md @@ -0,0 +1,120 @@ +# Frontend Development Standards + +## Naming Convention (LOCKED) + +The shopdb-flask naming convention lives in the root `CONTRIBUTING.md`. Read it before naming any variable, component, API param, or CSS class. + +Frontend-specific reminders pulled from the convention: + +- Variables holding API field values: match the API field name exactly. Do NOT convert to camelCase. (`response.machineid`, NOT `response.machineId`) +- Pure JS variables: camelCase (`currentUser`, `isLoading`) +- Vue components: PascalCase, spelled out (`AssetDetail.vue`, `MachineForm.vue`) +- CSS classes: lowercase with dashes (`asset-detail`, `machine-form`) +- API params sent to backend: match DB column names without underscores (`params.locationid = 5`, NOT `params.location_id`) +- No emojis, em-dashes, smart quotes, or Unicode arrows anywhere. Plain ASCII only. +- Banned shorthand as standalone variables: `cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`. Spell them out (`canvasContext`, `response`, `manager`, etc.). Suffix usage like `printers_bp` is allowed. + +Pre-commit hook at `scripts/check-naming-and-style.sh` enforces these rules. + +## CSS Styling Standards + +### Use CSS Variables for ALL Colors + +**NEVER hardcode colors in component styles.** Always use CSS variables defined in `src/assets/style.css`. + +Available variables: +```css +--primary /* Primary brand color */ +--primary-dark /* Darker primary for hover states */ +--secondary /* Secondary/muted color */ +--success /* Success states (green) */ +--warning /* Warning states (orange) */ +--danger /* Error/danger states (red) */ +--bg /* Page background */ +--bg-card /* Card/panel background */ +--text /* Primary text color */ +--text-light /* Secondary/muted text */ +--border /* Border color */ +--link /* Link color (bright blue in dark mode) */ +``` + +**Bad:** +```css +.my-card { + background: white; + color: #1a1a1a; +} +``` + +**Good:** +```css +.my-card { + background: var(--bg-card); + color: var(--text); +} +``` + +### Detail Pages - Use Global Styles + +All detail pages (MachineDetail, PCDetail, PrinterDetail, ApplicationDetail) should use the **unified global styles** from `style.css`: + +- `.detail-page` - Container wrapper +- `.hero-card` - Main hero section with image and info +- `.hero-image`, `.hero-content`, `.hero-title`, `.hero-meta`, `.hero-details` +- `.section-card` - Info sections +- `.section-title` - Section headers +- `.info-list`, `.info-row`, `.info-label`, `.info-value` +- `.content-grid`, `.content-column` - Two-column layout +- `.audit-footer` - Created/modified timestamps + +**Only add scoped styles for page-specific elements** (e.g., supplies grid for printers, version list for applications). + +### PrinterDetail.vue is the Master Template for Detail Pages + +Use `PrinterDetail.vue` as the reference for new detail pages. Follow its structure and styling patterns. + +### List Pages - Use Global Styles + +All list pages should use the **unified global styles** from `style.css`: + +- `.page-header` - Header with title and action button +- `.filters` - Search and filter controls +- `.card` - Main content container +- `.table-container` - Scrollable table wrapper +- `table`, `th`, `td` - Table styling +- `.pagination` - Page navigation +- `.badge`, `.badge-success`, etc. - Status badges +- `.actions` - Action button column + +**PrintersList.vue is the Master Template for List Pages** + +Use `PrintersList.vue` as the reference for new list pages. It has NO scoped styles - everything uses global CSS. + +**Only add scoped styles for page-specific elements** (e.g., icon cells for applications, stats badge for knowledge base). + +### Dark Mode Support + +Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page. + +## Component Organization + +- **Global styles**: `src/assets/style.css` +- **Page-specific styles**: Scoped ` diff --git a/frontend/src/components/ColorSwatchPicker.vue b/frontend/src/components/ColorSwatchPicker.vue new file mode 100644 index 00000000..ffc197a8 --- /dev/null +++ b/frontend/src/components/ColorSwatchPicker.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/components/CustomFieldsInputs.vue b/frontend/src/components/CustomFieldsInputs.vue new file mode 100644 index 00000000..59ef192b --- /dev/null +++ b/frontend/src/components/CustomFieldsInputs.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/components/CustomFieldsSection.vue b/frontend/src/components/CustomFieldsSection.vue new file mode 100644 index 00000000..f86c7663 --- /dev/null +++ b/frontend/src/components/CustomFieldsSection.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/components/EmailReportButton.vue b/frontend/src/components/EmailReportButton.vue new file mode 100644 index 00000000..7a9f3e85 --- /dev/null +++ b/frontend/src/components/EmailReportButton.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/components/EmbeddedLocationMap.vue b/frontend/src/components/EmbeddedLocationMap.vue new file mode 100644 index 00000000..22180a69 --- /dev/null +++ b/frontend/src/components/EmbeddedLocationMap.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/components/EmployeeSearch.vue b/frontend/src/components/EmployeeSearch.vue new file mode 100644 index 00000000..9be6b154 --- /dev/null +++ b/frontend/src/components/EmployeeSearch.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/frontend/src/components/LocationMapTooltip.vue b/frontend/src/components/LocationMapTooltip.vue new file mode 100644 index 00000000..e4532d27 --- /dev/null +++ b/frontend/src/components/LocationMapTooltip.vue @@ -0,0 +1,268 @@ + + + + + + + diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue new file mode 100644 index 00000000..090d976e --- /dev/null +++ b/frontend/src/components/Modal.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/frontend/src/components/PaginationBar.vue b/frontend/src/components/PaginationBar.vue new file mode 100644 index 00000000..6bced812 --- /dev/null +++ b/frontend/src/components/PaginationBar.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/components/ShopFloorMap.vue b/frontend/src/components/ShopFloorMap.vue new file mode 100644 index 00000000..c6918a1a --- /dev/null +++ b/frontend/src/components/ShopFloorMap.vue @@ -0,0 +1,790 @@ + + + + + diff --git a/frontend/src/components/ToastHost.vue b/frontend/src/components/ToastHost.vue new file mode 100644 index 00000000..74b06614 --- /dev/null +++ b/frontend/src/components/ToastHost.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/components/WarrantyPanel.vue b/frontend/src/components/WarrantyPanel.vue new file mode 100644 index 00000000..f5464044 --- /dev/null +++ b/frontend/src/components/WarrantyPanel.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/frontend/src/composables/enabledPlugins.js b/frontend/src/composables/enabledPlugins.js new file mode 100644 index 00000000..b95e2a39 --- /dev/null +++ b/frontend/src/composables/enabledPlugins.js @@ -0,0 +1,42 @@ +// Enabled-plugin gating for the router. Fetches the enabled plugin-name list +// ONCE (cached promise) so a disabled backend plugin's frontend routes become +// unreachable by direct URL. Fail-open: any fetch error treats every plugin as +// enabled, so an API blip can never brick navigation. +import { pluginsApi } from '../api' + +let enabledPromise = null +let enabledSet = null // resolved Set, or null while pending / on failure + +// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this +// works on unauthenticated kiosk routes (e.g. /tv) too. +export function loadEnabledPlugins() { + if (!enabledPromise) { + enabledPromise = pluginsApi.enabled() + .then(response => { + const names = response?.data?.data + // Only trust a real array; anything else -> fail open (null). + enabledSet = Array.isArray(names) ? new Set(names) : null + return enabledSet + }) + .catch(() => { + // Fail open: leave enabledSet null so isPluginEnabled returns true. + enabledSet = null + return null + }) + } + return enabledPromise +} + +// True if the plugin is enabled OR the list is unknown (fail-open). A null +// enabledSet means we never got a trustworthy answer, so allow everything. +export function isPluginEnabled(name) { + if (!name) return true + if (enabledSet === null) return true + return enabledSet.has(name) +} + +// Test/hot-reload hook: forget the cached result so the next call refetches. +export function resetEnabledPlugins() { + enabledPromise = null + enabledSet = null +} diff --git a/frontend/src/composables/identifierSettings.js b/frontend/src/composables/identifierSettings.js new file mode 100644 index 00000000..b3864463 --- /dev/null +++ b/frontend/src/composables/identifierSettings.js @@ -0,0 +1,71 @@ +// Per-type enable/disable flags for optional asset identifiers, read from the +// settings table. Keys follow identifier___enabled. A legacy +// global key identifier__enabled is honored as a fallback for older +// installs. Missing = enabled, so a fresh install shows every identifier. +import { reactive } from 'vue' +import { settingsApi } from '../api' + +// scope[name][assettype] = boolean. legacy[name] = boolean (old global flag). +const state = reactive({ + scope: {}, + legacy: {}, + loaded: false +}) + +let inflight = null + +const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device|measuring_tool))?_enabled$/ + +function applySetting(key, value) { + const match = KEY_RE.exec(key) + if (!match) return + const name = match[1] + const assettype = match[2] + if (assettype) { + if (!state.scope[name]) state.scope[name] = {} + state.scope[name][assettype] = value !== false + } else { + state.legacy[name] = value !== false + } +} + +function fetchFlags() { + inflight = settingsApi.list() + .then(({ data }) => { + ;(data.data || []).forEach(s => applySetting(s.key, s.value)) + state.loaded = true + }) + .catch(() => { state.loaded = true }) + .finally(() => { inflight = null }) + return inflight +} + +function loadFlags() { + if (!state.loaded && !inflight) fetchFlags() +} + +// Re-read flags from the server. Call after an identifier setting changes so +// other open views pick it up without a full page reload. +export function reloadIdentifierFlags() { + return fetchFlags() +} + +// Optimistically update one flag in the shared state (e.g. right after a +// Settings toggle) so dependent views react immediately. +export function setIdentifierFlag(name, assettype, enabled) { + applySetting(`identifier_${name}_${assettype}_enabled`, enabled) +} + +// True when identifier `name` should show on `assettype`. Per-type flag wins, +// then the legacy global flag, then default-on. +function isEnabled(name, assettype) { + const perType = state.scope[name] + if (perType && assettype in perType) return perType[assettype] + if (name in state.legacy) return state.legacy[name] + return true +} + +export function useIdentifierFlags() { + loadFlags() + return { state, isEnabled } +} diff --git a/frontend/src/composables/listQuery.js b/frontend/src/composables/listQuery.js new file mode 100644 index 00000000..cee61558 --- /dev/null +++ b/frontend/src/composables/listQuery.js @@ -0,0 +1,102 @@ +import { ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +// Keep a list page's current page and search term in the URL query so the +// browser Back button restores them. Without this a list keeps page in local +// state and remounts at page 1 after visiting a detail page and going Back. +// +// Usage in a list view: +// const { page, search, setPage, setSearch } = useListQuery({ onChange: loadRows }) +// goToPage(p) -> setPage(p); loadRows() +// debouncedSearch -> setSearch(search.value); loadRows() +// filter reset -> setPage(1); loadRows() +// +// Options: +// onChange callback run when the query changes from outside (Back/Forward, +// deep link) so the list reloads at the restored page. +// extraKeys optional extra query keys to persist (e.g. ['typeid']); each +// gets a ref exposed under the returned `extras` object. +export function useListQuery(options = {}) { + const route = useRoute() + const router = useRouter() + const onChange = options.onChange || (() => {}) + const extraKeys = options.extraKeys || [] + + const page = ref(parseInt(route.query.page, 10) || 1) + const search = ref(route.query.q || '') + + const extras = {} + for (const key of extraKeys) { + extras[key] = ref(route.query[key] || '') + } + + // Build the next query. Keep unrelated keys intact. Write page only when > 1 + // and search only when non-empty so the clean state is a bare path. + function buildQuery() { + const query = { ...route.query } + if (page.value > 1) query.page = String(page.value) + else delete query.page + if (search.value) query.q = search.value + else delete query.q + for (const key of extraKeys) { + if (extras[key].value) query[key] = String(extras[key].value) + else delete query[key] + } + return query + } + + // replace (not push) so paging does not spam history; Back leaves the list. + function syncUrl() { + const query = buildQuery() + const current = route.query + const keys = new Set([...Object.keys(query), ...Object.keys(current)]) + let same = true + for (const key of keys) { + if (String(query[key] ?? '') !== String(current[key] ?? '')) { + same = false + break + } + } + if (!same) router.replace({ query }) + } + + function setPage(newPage) { + page.value = newPage + syncUrl() + } + + // Changing the search resets to page 1. + function setSearch(term) { + search.value = term + page.value = 1 + syncUrl() + } + + // Changing an extra filter resets to page 1. + function setExtra(key, value) { + extras[key].value = value + page.value = 1 + syncUrl() + } + + // Re-sync refs when the query changes from outside (Back/Forward, deep link). + // Fire onChange only when page/search/extras actually changed so a self + // syncUrl() call does not trigger a redundant reload. + watch(() => route.query, (newQuery) => { + const newPage = parseInt(newQuery.page, 10) || 1 + const newSearch = newQuery.q || '' + let changed = false + if (newPage !== page.value) { page.value = newPage; changed = true } + if (newSearch !== search.value) { search.value = newSearch; changed = true } + for (const key of extraKeys) { + const newValue = newQuery[key] || '' + if (newValue !== String(extras[key].value || '')) { + extras[key].value = newValue + changed = true + } + } + if (changed) onChange() + }) + + return { page, search, extras, setPage, setSearch, setExtra } +} diff --git a/frontend/src/composables/mapConfig.js b/frontend/src/composables/mapConfig.js new file mode 100644 index 00000000..5009a5d9 --- /dev/null +++ b/frontend/src/composables/mapConfig.js @@ -0,0 +1,69 @@ +// Facility floor-map blueprint config, read from the settings table so each +// site instance (ADR-004) renders its own floor plan instead of a hardcoded +// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height. +// Missing keys fall back to the generic placeholder so a fresh or offline +// install still renders; each site uploads its own blueprint in Settings. +import { reactive } from 'vue' +import { settingsApi } from '../api' +import { withBase } from '../utils/basePath' + +// Fallback defaults - match the seeded map_blueprint_* setting defaults. +const DEFAULTS = { + blueprintLight: '/static/images/floorplan-placeholder.svg', + blueprintDark: '/static/images/floorplan-placeholder.svg', + width: 3300, + height: 2550 +} + +// Shared reactive config. Import as `state` to read width/height/blueprint. +export const state = reactive({ ...DEFAULTS, loaded: false }) + +let inflight = null + +function applySetting(key, value) { + if (value === null || value === undefined || value === '') return + if (key === 'map_blueprint_light') state.blueprintLight = value + else if (key === 'map_blueprint_dark') state.blueprintDark = value + else if (key === 'map_width') { + const n = parseInt(value, 10) + if (!isNaN(n) && n > 0) state.width = n + } else if (key === 'map_height') { + const n = parseInt(value, 10) + if (!isNaN(n) && n > 0) state.height = n + } +} + +function fetchConfig() { + inflight = settingsApi.list({ category: 'map' }) + .then(({ data }) => { + ;(data.data || []).forEach(s => applySetting(s.key, s.value)) + state.loaded = true + }) + .catch(() => { state.loaded = true }) + .finally(() => { inflight = null }) + return inflight +} + +// Fetch the map config once (shared across all map components). Returns a +// promise that resolves when state is populated, so a caller can await it +// before initializing a Leaflet map that needs the dimensions. +export function loadMapConfig() { + if (state.loaded) return Promise.resolve() + if (inflight) return inflight + return fetchConfig() +} + +// Re-read config from the server after a map setting changes. +export function reloadMapConfig() { + return fetchConfig() +} + +// Blueprint image URL for the given theme ('light' | 'dark'). +export function blueprintUrlFor(theme) { + return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark) +} + +export function useMapConfig() { + loadMapConfig() + return { state, blueprintUrlFor } +} diff --git a/frontend/src/composables/settingsCatalog.js b/frontend/src/composables/settingsCatalog.js new file mode 100644 index 00000000..81015ff4 --- /dev/null +++ b/frontend/src/composables/settingsCatalog.js @@ -0,0 +1,86 @@ +// Settings catalog: the static core groups (settingsNav.js) merged with the +// plugin-contributed cards from the ADR-010 get_settings_cards hook, served by +// GET /api/pluginui/settings-cards. Both SettingsLayout (rail) and SettingsIndex +// (landing) read the merged catalog so plugins add settings cards with no core +// edit. Fetched once into a module-level ref and shared across callers. +import { ref } from 'vue' +import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets } from 'lucide-vue-next' +import api from '../api' +import { settingsGroups } from '../views/settings/settingsNav' + +// Icon string keys (as returned by the hook) mapped to Lucide components, +// same idea as the sidebar iconMap. Unknown keys fall back to a puzzle piece. +const iconMap = { + ruler: Ruler, + wrench: Wrench, + cog: Cog, + package: Package, + settings: Settings, + puzzle: Puzzle, + sliders: SlidersHorizontal, + palette: Palette, + bell: Bell, + network: Network, + printer: Printer, + droplets: Droplets, +} + +function resolveIcon(key) { + return iconMap[key] || Puzzle +} + +// Merge plugin cards into a fresh copy of the core groups. A card joins the +// group whose title matches its `group`; a new group is appended at the end. +function mergeCards(baseGroups, cards) { + const merged = baseGroups.map(group => ({ + title: group.title, + cards: [...group.cards], + })) + const byTitle = new Map(merged.map(group => [group.title, group])) + + for (const card of cards) { + const entry = { + to: card.to, + icon: resolveIcon(card.icon), + title: card.title, + description: card.description || '', + position: card.position ?? 99, + } + let group = byTitle.get(card.group) + if (!group) { + group = { title: card.group, cards: [] } + byTitle.set(card.group, group) + merged.push(group) + } + group.cards.push(entry) + } + + // Order plugin cards within a group by position; core cards keep their order. + for (const group of merged) { + group.cards.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + } + return merged +} + +// Shared across component instances: seeded with the core groups, replaced with +// the merged catalog once the hook endpoint answers. +const groups = ref(settingsGroups) +let loaded = false + +async function loadCatalog() { + try { + const response = await api.get('/pluginui/settings-cards') + groups.value = mergeCards(settingsGroups, response.data.data || []) + } catch (error) { + // Degrade to the core-only catalog; the rail still works without plugins. + groups.value = settingsGroups + } +} + +export function useSettingsCatalog() { + if (!loaded) { + loaded = true + loadCatalog() + } + return { groups } +} diff --git a/frontend/src/composables/setupState.js b/frontend/src/composables/setupState.js new file mode 100644 index 00000000..c1e2905e --- /dev/null +++ b/frontend/src/composables/setupState.js @@ -0,0 +1,28 @@ +// Tracks whether first-run setup is complete, so the app can steer a fresh +// admin into the /setup wizard. Defaults to "complete" so the wizard never +// flashes before the real value loads. +import { ref } from 'vue' +import { settingsApi } from '../api' + +export const setupComplete = ref(true) +// Session-only: a fresh admin who clicks "Skip for now" is not nagged again +// until the next login/reload. +export const setupSkipped = ref(false) +const loaded = ref(false) + +export function isSetupLoaded() { + return loaded.value +} + +export async function refreshSetupState() { + try { + const response = await settingsApi.get('setup_complete') + const value = response.data?.data?.value + setupComplete.value = value === true || value === 'true' + } catch (err) { + // If we cannot read it, assume complete so we do not trap the user. + setupComplete.value = true + } + loaded.value = true + return setupComplete.value +} diff --git a/frontend/src/composables/systemSettings.js b/frontend/src/composables/systemSettings.js new file mode 100644 index 00000000..30c339c6 --- /dev/null +++ b/frontend/src/composables/systemSettings.js @@ -0,0 +1,359 @@ +// Shared plumbing for the individual system-settings pages. +// Each settings page calls useSystemSettings() in its own setup, so state is +// per-page (no cross-page leak). The composable owns the load-all/save/toggle/ +// upload machinery plus the static display catalogs, keeping the pages thin. +// Per-page connection-status computeds (zabbix/dell/smtp/saml) stay in the pages. +import { ref, reactive } from 'vue' +import { settingsApi, computersApi } from '../api' +import { setIdentifierFlag } from './identifierSettings' +import { apiError } from '../utils/apiError' + +// Branding logo widgets. kind maps to the backend endpoint; key is the setting +// the resulting URL is stored under. +export const brandingLogos = [ + { kind: 'site', key: 'site_logo', label: 'Site logo', accept: 'image/*', + placeholder: '/ge-aerospace-logo.svg', + hint: 'Shown in the app header and login. Upload an image or type a path/URL.' }, + { kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*', + placeholder: '/ge-monogram.svg', + hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' }, + { kind: 'badge', key: 'badge_logo', label: 'Machine badge logo', accept: 'image/*', + placeholder: '/ge-aerospace-logo.svg', + hint: 'Logo printed on machine badges. Upload an image or type a path/URL.' }, + { kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico', + placeholder: '(blank = shipped /favicon.svg)', + hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' }, +] + +// Asset identifier matrix: identifier x asset type. Keys follow +// identifier___enabled. Missing = enabled (default on). +export const identifierRows = [ + { name: 'gaugelabreference', label: 'Gauge Lab Reference' }, + { name: 'maintenancereference', label: 'Maintenance Reference' }, + { name: 'fqdn', label: 'FQDN / Hostname' } +] +export const assetTypeCols = [ + { key: 'machine', label: 'Machine' }, + { key: 'computer', label: 'PC' }, + { key: 'printer', label: 'Printer' }, + { key: 'network_device', label: 'Network' }, + { key: 'measuring_tool', label: 'Measuring Tool' } +] + +// Global-search domain toggles: keys follow search__enabled. +// Missing = enabled (default on). +export const searchDomains = [ + { key: 'application', label: 'Applications' }, + { key: 'knowledgebase', label: 'Knowledge Base' }, + { key: 'employee', label: 'Employees' }, + { key: 'machine', label: 'Machines' }, + { key: 'computer', label: 'PCs' }, + { key: 'printer', label: 'Printers' }, + { key: 'network_device', label: 'Network Devices' }, + { key: 'measuring_tool', label: 'Measuring Tools' }, + { key: 'notification', label: 'Notifications' }, + { key: 'subnet', label: 'Subnets' } +] + +export function useSystemSettings() { + // Every settings key across all pages. A page binds only its own subset. + const settings = reactive({ + // Zabbix + zabbix_enabled: false, + zabbix_url: '', + zabbix_token: '', + // Dell warranty + warranty_dell_enabled: false, + warranty_dell_clientid: '', + warranty_dell_clientsecret: '', + warranty_dell_tokenurl: '', + warranty_dell_apiurl: '', + // ServiceNow + servicenow_enabled: true, + servicenow_search_url: '', + servicenow_ticket_prefixes: '', + servicenow_incident_url: '', + servicenow_change_url: '', + // Branding + site_logo: '', + qr_logo: '', + badge_logo: '', + site_favicon: '', + brand_primary_color: '', + brand_primary_dark_color: '', + brand_accent_color: '', + brand_sidebar_color: '', + // Printing and labels + qr_target_printer: '', + qr_target_usb: '', + usb_label_style: 'barcode', + qr_target_machine: '', + qr_target_computer: '', + qr_target_network_device: '', + qr_target_measuring_tool: '', + label_default_style: 'card', + label_default_codetype: 'qr', + label_default_encodes_machine: 'assetnumber', + label_default_encodes_computer: 'assetpage', + label_default_encodes_printer: 'assetpage', + label_default_encodes_network_device: 'assetpage', + label_default_encodes_measuring_tool: 'location', + // Email + smtp_enabled: false, + smtp_host: '', + smtp_port: 587, + smtp_username: '', + smtp_password: '', + smtp_use_tls: true, + smtp_from_address: '', + smtp_from_name: 'ShopDB', + alert_recipients: '', + // Audit + audit_retention_days: 90, + // Floor map blueprint (per-facility) + map_blueprint_light: '', + map_blueprint_dark: '', + map_width: 3300, + map_height: 2550, + // SAML + saml_enabled: false, + saml_idp_metadata_url: '', + saml_entity_id: '', + saml_acs_url: '', + saml_allow_local_login: true, + saml_auto_create_users: true, + saml_admin_group: '' + }) + + const identifierMatrix = reactive({}) + const searchMatrix = reactive({}) + const pcTypeMappings = ref([]) // [{ pxetype, computertype }] + const computerTypes = ref([]) // ComputerType names for the dropdown + + const loading = ref(true) + const saving = ref(false) + const mapUploading = ref(false) + const brandingUploading = ref(false) + const error = ref('') + const success = ref('') + + function flashSaved() { + success.value = 'Setting saved' + setTimeout(() => { success.value = '' }, 2000) + } + + 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 + } + + function searchValue(domainKey) { + const key = `search_${domainKey}_enabled` + return key in searchMatrix ? searchMatrix[key] : true + } + + // Load all settings and fan them into settings / matrices / pctype rows. + async function loadSettings() { + try { + loading.value = true + const { data } = await settingsApi.list() + + const pctypeRows = [] + for (const setting of data.data) { + if (setting.key in settings) { + settings[setting.key] = setting.value + } else if (/^identifier_.+_(machine|computer|printer|network_device|measuring_tool)_enabled$/.test(setting.key)) { + identifierMatrix[setting.key] = setting.value !== false + } else if (/^search_.+_enabled$/.test(setting.key)) { + searchMatrix[setting.key] = setting.value !== false + } else if (setting.key.startsWith('pctypemap_')) { + pctypeRows.push({ + pxetype: setting.key.slice('pctypemap_'.length), + computertype: setting.value + }) + } + } + pcTypeMappings.value = pctypeRows.sort((a, b) => a.pxetype.localeCompare(b.pxetype)) + } catch (e) { + error.value = 'Failed to load settings' + console.error(e) + } finally { + loading.value = false + } + } + + // Computer type options for the pctype mapping dropdown (own request). + async function loadComputerTypes() { + try { + const typesResponse = await computersApi.types.list({ perpage: 100 }) + computerTypes.value = (typesResponse.data.data || []).map(t => t.computertype) + } catch (typesError) { + console.error('Failed to load computer types', typesError) + } + } + + async function saveSetting(key, value) { + try { + saving.value = true + error.value = '' + success.value = '' + await settingsApi.update(key, value) + settings[key] = value + flashSaved() + } catch (e) { + error.value = apiError(e, 'Failed to save setting') + console.error(e) + } finally { + saving.value = false + } + } + + async function toggleSetting(key) { + await saveSetting(key, !settings[key]) + } + + // Toggle a per-type identifier flag. The key may not be seeded on older + // installs, so create 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) + flashSaved() + } catch (e) { + error.value = apiError(e, 'Failed to save setting') + console.error(e) + } finally { + saving.value = false + } + } + + async function toggleSearchDomain(domainKey) { + const key = `search_${domainKey}_enabled` + const newValue = !searchValue(domainKey) + const label = searchDomains.find(d => d.key === domainKey)?.label || domainKey + 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: 'search', + description: `Include ${label} in global search results` + }) + } else { + throw e + } + } + searchMatrix[key] = newValue + flashSaved() + } catch (e) { + error.value = apiError(e, 'Failed to save setting') + console.error(e) + } finally { + saving.value = false + } + } + + async function changePcTypeMapping(pxetype, computertype) { + const key = `pctypemap_${pxetype}` + try { + saving.value = true + error.value = '' + success.value = '' + await settingsApi.update(key, computertype) + const row = pcTypeMappings.value.find(r => r.pxetype === pxetype) + if (row) row.computertype = computertype + flashSaved() + } catch (e) { + error.value = apiError(e, 'Failed to save setting') + console.error(e) + } finally { + saving.value = false + } + } + + async function uploadBlueprint(theme, event) { + const file = event.target.files[0] + if (!file) return + mapUploading.value = true + error.value = '' + success.value = '' + try { + const { data } = await settingsApi.uploadMapBlueprint(theme, file) + const url = data?.data?.value + if (theme === 'light') settings.map_blueprint_light = url + else settings.map_blueprint_dark = url + success.value = 'Blueprint uploaded' + setTimeout(() => { success.value = '' }, 2000) + } catch (e) { + error.value = apiError(e, 'Upload failed') + } finally { + mapUploading.value = false + event.target.value = '' + } + } + + async function uploadLogo(kind, key, event) { + const file = event.target.files[0] + if (!file) return + brandingUploading.value = true + error.value = '' + success.value = '' + try { + const { data } = await settingsApi.uploadBrandingLogo(kind, file) + const url = data?.data?.value + if (url) settings[key] = url + success.value = 'Logo uploaded' + setTimeout(() => { success.value = '' }, 2000) + } catch (e) { + error.value = apiError(e, 'Upload failed') + } finally { + brandingUploading.value = false + event.target.value = '' + } + } + + return { + settings, + identifierMatrix, searchMatrix, pcTypeMappings, computerTypes, + loading, saving, mapUploading, brandingUploading, error, success, + loadSettings, loadComputerTypes, + saveSetting, toggleSetting, + identifierKey, matrixValue, toggleIdentifier, + searchValue, toggleSearchDomain, + changePcTypeMapping, + uploadBlueprint, uploadLogo, + } +} diff --git a/frontend/src/composables/toast.js b/frontend/src/composables/toast.js new file mode 100644 index 00000000..6ff949e1 --- /dev/null +++ b/frontend/src/composables/toast.js @@ -0,0 +1,35 @@ +// Global toast notifications. Import useToast() anywhere and call +// toast.success('...') / toast.error('...') / toast.info('...'). A single +// mounted in AppLayout renders the stack. +import { reactive } from 'vue' + +const state = reactive({ + items: [], +}) + +let nextId = 1 + +function dismiss(id) { + const index = state.items.findIndex(t => t.id === id) + if (index !== -1) state.items.splice(index, 1) +} + +function push(message, type = 'info', duration = 5000) { + if (!message) return + const id = nextId++ + state.items.push({ id, message, type }) + if (duration > 0) { + setTimeout(() => dismiss(id), duration) + } + return id +} + +export function useToast() { + return { + items: state.items, + dismiss, + success: (message, duration) => push(message, 'success', duration), + error: (message, duration) => push(message, 'error', duration ?? 8000), + info: (message, duration) => push(message, 'info', duration), + } +} diff --git a/frontend/src/composables/warrantyBadge.js b/frontend/src/composables/warrantyBadge.js new file mode 100644 index 00000000..9fa2b11b --- /dev/null +++ b/frontend/src/composables/warrantyBadge.js @@ -0,0 +1,44 @@ +// Shared warranty fetch + hero-badge derivation for asset detail pages. +// Pass a getter for the asset id; get back the warranties list (to feed a +// WarrantyPanel) and a heroWarranty for a compact status badge. +import { ref, computed, watch } from 'vue' +import { warrantyApi } from '../api' + +const STATUS_RANK = { expired: 0, expiring: 1, active: 2, unknown: 3 } +const STATUS_LABELS = { + active: 'Under Warranty', + expiring: 'Warranty Expiring', + expired: 'Warranty Expired', + unknown: 'Warranty', +} + +export function useWarrantyBadge(getAssetId) { + const warranties = ref([]) + + // Worst-case warranty drives the badge: expired > expiring > active > unknown. + const heroWarranty = computed(() => { + if (!warranties.value.length) return null + const worst = [...warranties.value] + .sort((a, b) => (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9))[0] + return { ...worst, label: STATUS_LABELS[worst.status] || 'Warranty' } + }) + + function warrantyDate(d) { + return d ? new Date(d + 'T00:00:00').toLocaleDateString() : '' + } + + async function load() { + const assetid = getAssetId() + if (!assetid) { warranties.value = []; return } + try { + const response = await warrantyApi.forAsset(assetid) + warranties.value = response.data.data || [] + } catch (err) { + warranties.value = [] + } + } + + watch(getAssetId, load, { immediate: true }) + + return { warranties, heroWarranty, warrantyDate, reloadWarranties: load } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 00000000..739dcfe4 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,21 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' + +// Locally-bundled Inter (air-gap safe, no Google Fonts fetch) +import '@fontsource-variable/inter' + +// Initialize theme on app load +import './stores/theme' +import { applyBranding } from './utils/siteSettings' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') + +// Apply per-site favicon + brand color once settings load (no-op on defaults). +applyBranding() diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 00000000..ade1a1ec --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,170 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' +import AppLayout from '../views/AppLayout.vue' +import SettingsLayout from '../views/settings/SettingsLayout.vue' +import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState' +import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins' +import { useToast } from '../composables/toast' + +// Auto-discover all route modules from routes/ directory +const routeModules = import.meta.glob('./routes/*.js', { eager: true }) +const rawChildren = Object.values(routeModules).flatMap(m => m.default) + +// Gather the settings pages (spread across plugin route files) and nest them +// under a single two-pane shell so the grouped rail stays put while the right +// pane swaps. Slides is a sidebar page that happens to live at /settings/slides; +// keep it full-width (not inside the settings rail). +const SETTINGS_STANDALONE = new Set(['settings/slides']) +const settingsChildren = [] +const otherChildren = [] +for (const route of rawChildren) { + const path = route.path + if (path === 'settings') { + // Old index becomes the shell's default child (keeps name 'settings'). + settingsChildren.unshift({ ...route, path: '' }) + } else if (typeof path === 'string' && path.startsWith('settings/') && !SETTINGS_STANDALONE.has(path)) { + settingsChildren.push({ ...route, path: path.replace(/^settings\//, '') }) + } else { + otherChildren.push(route) + } +} +const appChildren = [ + ...otherChildren, + { + path: 'settings', + component: SettingsLayout, + meta: { requiresAuth: true, requiresAdmin: true }, + children: settingsChildren, + }, +] + +const routes = [ + { + path: '/login', + name: 'login', + component: () => import('../views/Login.vue'), + meta: { guest: true } + }, + // Forced/self-service password change (standalone, authenticated) + { + path: '/change-password', + name: 'change-password', + component: () => import('../views/ChangePassword.vue'), + meta: { requiresAuth: true } + }, + // First-run setup wizard (standalone, admin-only) + { + path: '/setup', + name: 'setup', + component: () => import('../views/SetupWizard.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + // Standalone full-screen dashboards (no sidebar, no auth required) + { + path: '/shopfloor', + name: 'shopfloor', + component: () => import('../views/ShopfloorDashboard.vue') + }, + { + path: '/tv', + name: 'tv', + component: () => import('../views/TVDashboard.vue'), + meta: { plugin: 'slides' } + }, + // Print pages (standalone, no sidebar/header) + { + path: '/print/machine-badge/:id', + name: 'print-machine-badge', + component: () => import('../views/print/MachineBadge.vue') + }, + // Shared asset label/code generator for any asset type (public, like the + // other /print/* routes). assettype = machine|computer|printer| + // network_device|measuring_tool; id = the asset's plugin id. + { + path: '/print/asset-label/:assettype/:id', + name: 'print-asset-label', + component: () => import('../views/print/AssetLabel.vue') + }, + // Batch label sheets (ULINE 6-up / mini 72-up) for one asset type. + { + path: '/print/asset-label-batch/:assettype', + name: 'print-asset-label-batch', + component: () => import('../views/print/AssetLabelBatch.vue') + }, + { + path: '/print/printer-qr', + name: 'print-printer-qr-batch', + component: () => import('../views/print/PrinterQRBatch.vue'), + meta: { plugin: 'printers' } + }, + { + path: '/print/printer-qr/:id', + name: 'print-printer-qr-single', + component: () => import('../views/print/PrinterQRSingle.vue'), + meta: { plugin: 'printers' } + }, + { + path: '/print/usb-labels', + name: 'print-usb-labels', + component: () => import('../views/print/USBLabelBatch.vue'), + meta: { plugin: 'usb' } + }, + { + path: '/', + component: AppLayout, + children: appChildren + } +] + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes +}) + +// Navigation guard +router.beforeEach(async (to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + // Remember where they were headed so login can send them back there. + return next({ path: '/login', query: { redirect: to.fullPath } }) + } + if (to.meta.requiresAdmin && !authStore.isAdmin) { + return next('/') + } + if (to.meta.guest && authStore.isAuthenticated) { + return next('/') + } + + // Forced password change: an admin-set temporary password must be replaced + // before the user reaches the rest of the app. Let them log out. + if (authStore.isAuthenticated && authStore.mustChangePassword + && to.path !== '/change-password' && to.path !== '/login') { + return next('/change-password') + } + + // Plugin gating: a disabled backend plugin's frontend routes are dead ends. + // The enabled list is fetched once and cached; fail-open on error so a blip + // cannot brick navigation. Works unauthenticated (endpoint is jwt-optional). + if (to.meta.plugin) { + await loadEnabledPlugins() + if (!isPluginEnabled(to.meta.plugin)) { + useToast().info(`The ${to.meta.plugin} feature is not enabled.`) + return next('/') + } + } + + // First-run: steer a fresh admin into the setup wizard (once, until finished). + if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') { + if (!isSetupLoaded()) { + await refreshSetupState() + } + if (!setupComplete.value && !setupSkipped.value) { + return next('/setup') + } + } + + next() +}) + +export default router diff --git a/frontend/src/router/routes/applications.js b/frontend/src/router/routes/applications.js new file mode 100644 index 00000000..754aacc5 --- /dev/null +++ b/frontend/src/router/routes/applications.js @@ -0,0 +1,27 @@ +/** + * Applications routes (core feature) + */ +export default [ + { + path: 'applications', + name: 'applications', + component: () => import('../../views/applications/ApplicationsList.vue') + }, + { + path: 'applications/new', + name: 'application-new', + component: () => import('../../views/applications/ApplicationForm.vue'), + meta: { requiresAuth: true } + }, + { + path: 'applications/:id', + name: 'application-detail', + component: () => import('../../views/applications/ApplicationDetail.vue') + }, + { + path: 'applications/:id/edit', + name: 'application-edit', + component: () => import('../../views/applications/ApplicationForm.vue'), + meta: { requiresAuth: true } + } +] diff --git a/frontend/src/router/routes/computers.js b/frontend/src/router/routes/computers.js new file mode 100644 index 00000000..90619dee --- /dev/null +++ b/frontend/src/router/routes/computers.js @@ -0,0 +1,48 @@ +/** + * Computers plugin routes + */ +export default [ + { + path: 'pcs', + name: 'pcs', + component: () => import('../../views/pcs/PCsList.vue'), + meta: { plugin: 'computers' } + }, + { + path: 'pcs/new', + name: 'pc-new', + component: () => import('../../views/pcs/PCForm.vue'), + meta: { requiresAuth: true, plugin: 'computers' } + }, + { + path: 'pcs/:id', + name: 'pc-detail', + component: () => import('../../views/pcs/PCDetail.vue'), + meta: { plugin: 'computers' } + }, + { + path: 'pcs/:id/edit', + name: 'pc-edit', + component: () => import('../../views/pcs/PCForm.vue'), + meta: { requiresAuth: true, plugin: 'computers' } + }, + // Computer-specific settings + { + path: 'settings/pctypes', + name: 'pctypes', + component: () => import('../../views/settings/PCTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + }, + { + path: 'settings/operatingsystems', + name: 'operatingsystems', + component: () => import('../../views/settings/OperatingSystemsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + }, + { + path: 'settings/accessprotocols', + name: 'access-protocols', + component: () => import('../../views/settings/AccessProtocolsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + } +] diff --git a/frontend/src/router/routes/core.js b/frontend/src/router/routes/core.js new file mode 100644 index 00000000..34b094d4 --- /dev/null +++ b/frontend/src/router/routes/core.js @@ -0,0 +1,285 @@ +/** + * Core routes - Dashboard, Search, Map, Reports, Settings, Employees + */ +export default [ + { + path: '', + name: 'dashboard', + component: () => import('../../views/Dashboard.vue') + }, + { + path: 'search', + name: 'search', + component: () => import('../../views/SearchResults.vue') + }, + { + path: 'map', + name: 'map', + component: () => import('../../views/MapView.vue') + }, + { + path: 'map/editor', + name: 'map-editor', + component: () => import('../../views/MapEditor.vue'), + meta: { requiresAuth: true } + }, + { + path: 'reports', + name: 'reports', + component: () => import('../../views/reports/ReportsIndex.vue') + }, + { + path: 'reports/pc-relationships', + name: 'report-pc-relationships', + component: () => import('../../views/reports/PCRelationshipsReport.vue'), + meta: { plugin: 'computers' } + }, + { + path: 'reports/toner', + name: 'toner-report', + component: () => import('../../views/reports/TonerReport.vue'), + meta: { plugin: 'printers' } + }, + { + path: 'employees/:sso', + name: 'employee-detail', + component: () => import('../../views/employees/EmployeeDetail.vue'), + meta: { plugin: 'employees' } + }, + // Settings + { + path: 'settings', + name: 'settings', + component: () => import('../../views/settings/SettingsIndex.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/vendors', + name: 'vendors', + component: () => import('../../views/vendors/VendorsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/modeltypes', + name: 'modeltypes', + component: () => import('../../views/settings/ModelTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/locations', + name: 'locations', + component: () => import('../../views/settings/LocationsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/statuses', + name: 'statuses', + component: () => import('../../views/settings/StatusesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/models', + name: 'models', + component: () => import('../../views/settings/ModelsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/businessunits', + name: 'businessunits', + component: () => import('../../views/settings/BusinessUnitsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/supportteams', + name: 'supportteams', + component: () => import('../../views/settings/SupportTeamsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/dashboarddefaults', + name: 'dashboarddefaults', + component: () => import('../../views/settings/DashboardDefaultsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/site', + name: 'site-settings', + component: () => import('../../views/settings/SiteSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/slides', + name: 'slide-manager', + component: () => import('../../views/settings/SlideManager.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'slides' } + }, + { + path: 'settings/machinetypes', + name: 'machinetypes', + component: () => import('../../views/settings/MachineTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/networktypes', + name: 'network-types', + component: () => import('../../views/settings/NetworkTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/printertypes', + name: 'printer-types', + component: () => import('../../views/settings/PrinterTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/relationshiptypes', + name: 'relationship-types', + component: () => import('../../views/settings/RelationshipTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/locationtypes', + name: 'location-types', + component: () => import('../../views/settings/LocationTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/assettypes', + name: 'asset-types', + component: () => import('../../views/settings/AssetTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/customfields', + name: 'custom-fields', + component: () => import('../../views/settings/CustomFieldsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/employeedirectory', + name: 'employee-directory', + component: () => import('../../views/settings/EmployeeDirectory.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + // The old monolithic System Settings tab page is split into one routed page + // per section (below). This redirect keeps old bookmarks working: bare + // /settings/system and every /settings/system?tab= land on the right page. + { + path: 'settings/system', + name: 'system-settings', + redirect: to => { + const tabRedirects = { + integrations: '/settings/servicenow', + branding: '/settings/branding', + printing: '/settings/printing', + email: '/settings/email', + audit: '/settings/audit', + auth: '/settings/authentication', + identifiers: '/settings/assetidentifiers', + search: '/settings/globalsearch', + map: '/settings/floormap', + pctype: '/settings/pctypemapping' + } + // vue-router carries the original ?tab= query onto the target; harmless + // since the new pages ignore it, and the path is what routes the view. + return tabRedirects[to.query.tab] || '/settings/servicenow' + }, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/servicenow', + name: 'servicenow-settings', + component: () => import('../../views/settings/ServiceNowSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/zabbix', + name: 'zabbix-settings', + component: () => import('../../views/settings/ZabbixSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/dellwarranty', + name: 'dell-warranty-settings', + component: () => import('../../views/settings/DellWarrantySettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/pctypemapping', + name: 'pctype-mapping-settings', + component: () => import('../../views/settings/PCTypeMappingSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/branding', + name: 'branding-settings', + component: () => import('../../views/settings/BrandingSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/printing', + name: 'printing-settings', + component: () => import('../../views/settings/PrintingSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/email', + name: 'email-settings', + component: () => import('../../views/settings/EmailSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/audit', + name: 'audit-settings', + component: () => import('../../views/settings/AuditSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/authentication', + name: 'authentication-settings', + component: () => import('../../views/settings/AuthenticationSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/apitokens', + name: 'apitokens-settings', + component: () => import('../../views/settings/ApiTokensList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/assetidentifiers', + name: 'asset-identifiers-settings', + component: () => import('../../views/settings/AssetIdentifiersSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/globalsearch', + name: 'global-search-settings', + component: () => import('../../views/settings/GlobalSearchSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/floormap', + name: 'floor-map-settings', + component: () => import('../../views/settings/FloorMapSettings.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', + component: () => import('../../views/settings/AuditLogs.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/users', + name: 'users', + component: () => import('../../views/settings/UsersList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + } +] diff --git a/frontend/src/router/routes/geenforce.js b/frontend/src/router/routes/geenforce.js new file mode 100644 index 00000000..a3953c2a --- /dev/null +++ b/frontend/src/router/routes/geenforce.js @@ -0,0 +1,30 @@ +/** + * GE-Enforce plugin routes. + * + * A top-level section (not under /settings) - the manifest editor + fleet + * reports are a large operational surface, so they get their own full-width + * shell with tabs. meta.plugin = 'geenforce' so the ADR-009 guard hides the + * section when the plugin is disabled. Admin-only. + */ +export default [ + { + path: 'geenforce', + component: () => import('../../views/geenforce/GeEnforceLayout.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }, + children: [ + { path: '', redirect: '/geenforce/manifests' }, + { + path: 'manifests', + name: 'geenforce-manifests', + component: () => import('../../views/geenforce/ManifestEditor.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' } + }, + { + path: 'reports', + name: 'geenforce-reports', + component: () => import('../../views/geenforce/EnforcementReports.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' } + } + ] + } +] diff --git a/frontend/src/router/routes/knowledgebase.js b/frontend/src/router/routes/knowledgebase.js new file mode 100644 index 00000000..0bd05e3b --- /dev/null +++ b/frontend/src/router/routes/knowledgebase.js @@ -0,0 +1,29 @@ +/** + * Knowledge Base plugin routes + */ +export default [ + { + path: 'knowledgebase', + name: 'knowledgebase', + component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue'), + meta: { plugin: 'knowledgebase' } + }, + { + path: 'knowledgebase/new', + name: 'knowledgebase-new', + component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'), + meta: { requiresAuth: true, plugin: 'knowledgebase' } + }, + { + path: 'knowledgebase/:id', + name: 'knowledgebase-detail', + component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue'), + meta: { plugin: 'knowledgebase' } + }, + { + path: 'knowledgebase/:id/edit', + name: 'knowledgebase-edit', + component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'), + meta: { requiresAuth: true, plugin: 'knowledgebase' } + } +] diff --git a/frontend/src/router/routes/machines.js b/frontend/src/router/routes/machines.js new file mode 100644 index 00000000..a3e46602 --- /dev/null +++ b/frontend/src/router/routes/machines.js @@ -0,0 +1,29 @@ +/** + * Machines plugin routes + */ +export default [ + { + path: 'machines', + name: 'machines', + component: () => import('../../views/machines/MachinesList.vue'), + meta: { plugin: 'machines' } + }, + { + path: 'machines/new', + name: 'machine-new', + component: () => import('../../views/machines/MachineForm.vue'), + meta: { requiresAuth: true, plugin: 'machines' } + }, + { + path: 'machines/:id', + name: 'machine-detail', + component: () => import('../../views/machines/MachineDetail.vue'), + meta: { plugin: 'machines' } + }, + { + path: 'machines/:id/edit', + name: 'machine-edit', + component: () => import('../../views/machines/MachineForm.vue'), + meta: { requiresAuth: true, plugin: 'machines' } + } +] diff --git a/frontend/src/router/routes/measuringtools.js b/frontend/src/router/routes/measuringtools.js new file mode 100644 index 00000000..fb609170 --- /dev/null +++ b/frontend/src/router/routes/measuringtools.js @@ -0,0 +1,54 @@ +/** + * Measuring-tools plugin routes. + * + * Every route carries meta.plugin = 'measuringtools' so the ADR-009 router + * guard redirects to the dashboard when the backend plugin is disabled. List + * and detail are public (no requiresAuth, matching the other asset types); the + * form requires auth; the settings subtype page requires admin. + */ +export default [ + { + path: 'measuringtools', + name: 'measuringtools', + component: () => import('../../views/measuringtools/MeasuringToolsList.vue'), + meta: { plugin: 'measuringtools' } + }, + { + path: 'measuringtools/new', + name: 'measuringtool-new', + component: () => import('../../views/measuringtools/MeasuringToolForm.vue'), + meta: { requiresAuth: true, plugin: 'measuringtools' } + }, + { + // Resolve a tool from its core asset id (search rows / cross-links carry + // assetid, not the extension id). Shares the detail component. + path: 'measuringtools/by-asset/:assetid', + name: 'measuringtool-by-asset', + component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'), + meta: { plugin: 'measuringtools' } + }, + { + path: 'measuringtools/:id', + name: 'measuringtool-detail', + component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'), + meta: { plugin: 'measuringtools' } + }, + { + path: 'measuringtools/:id/edit', + name: 'measuringtool-edit', + component: () => import('../../views/measuringtools/MeasuringToolForm.vue'), + meta: { requiresAuth: true, plugin: 'measuringtools' } + }, + { + path: 'reports/calibration', + name: 'calibration-report', + component: () => import('../../views/reports/CalibrationReport.vue'), + meta: { plugin: 'measuringtools' } + }, + { + path: 'settings/measuringtooltypes', + name: 'measuringtooltypes', + component: () => import('../../views/settings/MeasuringToolTypesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' } + } +] diff --git a/frontend/src/router/routes/network.js b/frontend/src/router/routes/network.js new file mode 100644 index 00000000..c721ac76 --- /dev/null +++ b/frontend/src/router/routes/network.js @@ -0,0 +1,53 @@ +/** + * Network plugin routes + */ +export default [ + { + path: 'network', + name: 'network', + component: () => import('../../views/network/NetworkHub.vue'), + meta: { plugin: 'network' } + }, + { + path: 'network/new', + name: 'network-new', + component: () => import('../../views/network/NetworkDeviceForm.vue'), + meta: { requiresAuth: true, plugin: 'network' } + }, + { + path: 'network/:id', + name: 'network-detail', + component: () => import('../../views/network/NetworkDeviceDetail.vue'), + meta: { plugin: 'network' } + }, + { + path: 'network/:id/edit', + name: 'network-edit', + component: () => import('../../views/network/NetworkDeviceForm.vue'), + meta: { requiresAuth: true, plugin: 'network' } + }, + { + // Legacy path -> the Networks tab of the hub. + path: 'networks', + redirect: { path: '/network', query: { tab: 'networks' } } + }, + { + path: 'networks/:id', + name: 'network-subnet-detail', + component: () => import('../../views/network/SubnetDetail.vue'), + meta: { plugin: 'network' } + }, + // Network-specific settings + { + path: 'settings/vlans', + name: 'vlans', + component: () => import('../../views/settings/VLANsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' } + }, + { + path: 'settings/subnets', + name: 'subnets', + component: () => import('../../views/settings/SubnetsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' } + } +] diff --git a/frontend/src/router/routes/notifications.js b/frontend/src/router/routes/notifications.js new file mode 100644 index 00000000..4a267a98 --- /dev/null +++ b/frontend/src/router/routes/notifications.js @@ -0,0 +1,41 @@ +/** + * Notifications plugin routes + */ +export default [ + { + path: 'notifications', + name: 'notifications', + component: () => import('../../views/notifications/NotificationsList.vue'), + meta: { plugin: 'notifications' } + }, + { + path: 'notifications/new', + name: 'notification-new', + component: () => import('../../views/notifications/NotificationForm.vue'), + meta: { requiresAuth: true, plugin: 'notifications' } + }, + { + path: 'settings/notificationtypes', + name: 'notification-types', + component: () => import('../../views/notifications/NotificationTypesList.vue'), + meta: { requiresAuth: true, plugin: 'notifications' } + }, + { + path: 'notifications/:id', + name: 'notification-detail', + component: () => import('../../views/notifications/NotificationForm.vue'), + meta: { plugin: 'notifications' } + }, + { + path: 'notifications/:id/edit', + name: 'notification-edit', + component: () => import('../../views/notifications/NotificationForm.vue'), + meta: { requiresAuth: true, plugin: 'notifications' } + }, + { + path: 'calendar', + name: 'calendar', + component: () => import('../../views/CalendarView.vue'), + meta: { plugin: 'notifications' } + } +] diff --git a/frontend/src/router/routes/printers.js b/frontend/src/router/routes/printers.js new file mode 100644 index 00000000..5bb001de --- /dev/null +++ b/frontend/src/router/routes/printers.js @@ -0,0 +1,42 @@ +/** + * Printers plugin routes + */ +export default [ + { + path: 'printers', + name: 'printers', + component: () => import('../../views/printers/PrintersList.vue'), + meta: { plugin: 'printers' } + }, + { + path: 'printers/new', + name: 'printer-new', + component: () => import('../../views/printers/PrinterForm.vue'), + meta: { requiresAuth: true, plugin: 'printers' } + }, + { + path: 'printers/:id', + name: 'printer-detail', + component: () => import('../../views/printers/PrinterDetail.vue'), + meta: { plugin: 'printers' } + }, + { + path: 'printers/:id/edit', + name: 'printer-edit', + component: () => import('../../views/printers/PrinterForm.vue'), + meta: { requiresAuth: true, plugin: 'printers' } + }, + // printer-specific settings + { + path: 'settings/modelsupplies', + name: 'model-supplies', + component: () => import('../../views/settings/ModelSuppliesList.vue'), + meta: { requiresAuth: true, plugin: 'printers' } + }, + { + path: 'settings/printerdrivers', + name: 'printer-drivers', + component: () => import('../../views/settings/PrinterDriversList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } + } +] diff --git a/frontend/src/router/routes/usb.js b/frontend/src/router/routes/usb.js new file mode 100644 index 00000000..3558327b --- /dev/null +++ b/frontend/src/router/routes/usb.js @@ -0,0 +1,29 @@ +/** + * USB plugin routes + */ +export default [ + { + path: 'usb', + name: 'usb', + component: () => import('../../views/usb/USBList.vue'), + meta: { plugin: 'usb' } + }, + { + path: 'usb/new', + name: 'usb-new', + component: () => import('../../views/usb/USBForm.vue'), + meta: { requiresAuth: true, plugin: 'usb' } + }, + { + path: 'usb/:id', + name: 'usb-detail', + component: () => import('../../views/usb/USBDetail.vue'), + meta: { plugin: 'usb' } + }, + { + path: 'usb/:id/edit', + name: 'usb-edit', + component: () => import('../../views/usb/USBForm.vue'), + meta: { requiresAuth: true, plugin: 'usb' } + } +] diff --git a/frontend/src/router/routes/warranty.js b/frontend/src/router/routes/warranty.js new file mode 100644 index 00000000..781fcf6d --- /dev/null +++ b/frontend/src/router/routes/warranty.js @@ -0,0 +1,17 @@ +/** + * Warranty plugin routes + */ +export default [ + { + path: 'warranties', + name: 'warranties', + component: () => import('../../views/warranty/WarrantiesList.vue'), + meta: { plugin: 'warranty' } + }, + { + path: 'reports/warranty', + name: 'warranty-report', + component: () => import('../../views/reports/WarrantyReport.vue'), + meta: { plugin: 'warranty' } + } +] diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 00000000..69a54a79 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,99 @@ +import { defineStore } from 'pinia' +import { authApi, employeesApi } from '../api' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + user: JSON.parse(localStorage.getItem('user') || 'null'), + token: localStorage.getItem('token') || null + }), + + getters: { + isAuthenticated: (state) => !!state.token, + username: (state) => state.user?.username || '', + roles: (state) => state.user?.roles || [], + hasRole: (state) => (role) => state.user?.roles?.includes(role) || false, + isAdmin: (state) => state.user?.roles?.includes('admin') || false, + // True when an admin-set temporary password must be changed before use. + mustChangePassword: (state) => !!state.user?.mustchangepassword, + // Full name from the employee directory (falls back to username/SSO). + displayName: (state) => state.user?.directoryname || state.user?.username || '', + // Employee photo URL if the directory has one for this SSO. The directory + // resolver already returns a usable URL (self-hosted upload or external HR + // path), so it is used as-is. + avatarUrl: (state) => state.user?.directoryphotourl || null + }, + + actions: { + async login(username, password) { + try { + const response = await authApi.login(username, password) + const { access_token, refresh_token, user } = response.data.data + + this.token = access_token + this.user = user + + this.token = access_token + localStorage.setItem('token', access_token) + localStorage.setItem('refreshToken', refresh_token) + await this.enrichFromDirectory() + + return { success: true } + } catch (error) { + const message = error.response?.data?.message || 'Login failed' + return { success: false, message } + } + }, + + async logout() { + try { + await authApi.logout() + } catch (e) { + // Ignore logout errors + } + + this.token = null + this.user = null + + localStorage.removeItem('token') + localStorage.removeItem('refreshToken') + localStorage.removeItem('user') + }, + + // Clear the forced-password-change flag after a successful change so the + // router guard stops steering the user to the change-password view. + clearMustChangePassword() { + if (this.user) { + this.user.mustchangepassword = false + localStorage.setItem('user', JSON.stringify(this.user)) + } + }, + + async fetchUser() { + try { + const response = await authApi.me() + this.user = response.data.data + await this.enrichFromDirectory() + } catch (error) { + this.logout() + } + }, + + // Pull the logged-in user's full name + photo from the employee directory + // by their SSO (= username). Best-effort: skipped/ignored when the employees + // plugin is off, the SSO is not in the directory, or the lookup fails. + async enrichFromDirectory() { + const sso = this.user?.username + if (sso && /^\d+$/.test(sso)) { + try { + const response = await employeesApi.lookup(sso) + const emp = response.data?.data + if (emp) { + this.user.directoryname = `${emp.First_Name || ''} ${emp.Last_Name || ''}`.trim() || null + this.user.directoryphotourl = emp.photourl || null + } + } catch (err) { /* directory unavailable - fall back to username */ } + } + localStorage.setItem('user', JSON.stringify(this.user)) + } + } +}) diff --git a/frontend/src/stores/theme.js b/frontend/src/stores/theme.js new file mode 100644 index 00000000..090190a6 --- /dev/null +++ b/frontend/src/stores/theme.js @@ -0,0 +1,39 @@ +import { ref, watch } from 'vue' + +const STORAGE_KEY = 'shopdb-theme' + +// Get initial theme from localStorage or system preference +function getInitialTheme() { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) return stored + + // Fall back to system preference + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + return 'light' +} + +export const currentTheme = ref(getInitialTheme()) + +// Apply theme to document +function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme) + localStorage.setItem(STORAGE_KEY, theme) +} + +// Initialize on load +applyTheme(currentTheme.value) + +// Watch for changes +watch(currentTheme, (newTheme) => { + applyTheme(newTheme) +}) + +export function toggleTheme() { + currentTheme.value = currentTheme.value === 'dark' ? 'light' : 'dark' +} + +export function setTheme(theme) { + currentTheme.value = theme +} diff --git a/frontend/src/utils/apiError.js b/frontend/src/utils/apiError.js new file mode 100644 index 00000000..0a3ed65d --- /dev/null +++ b/frontend/src/utils/apiError.js @@ -0,0 +1,9 @@ +// Extract a human-readable message from an API error. The backend nests the +// message at data.data.error.message (see error_response); older call sites read +// shallower paths, so try them all before falling back. +export function apiError(err, fallback = 'Something went wrong') { + return err?.response?.data?.data?.error?.message + || err?.response?.data?.error?.message + || err?.response?.data?.message + || fallback +} diff --git a/frontend/src/utils/assetTypes.js b/frontend/src/utils/assetTypes.js new file mode 100644 index 00000000..79b62d4b --- /dev/null +++ b/frontend/src/utils/assetTypes.js @@ -0,0 +1,63 @@ +// Single source of truth for asset-type display labels + detail routing. +// The map and shopfloor views used to each hardcode these identical maps. +// Keys are lowercase; both 'network_device' and 'network device' are accepted +// because the API sends the underscore form and some map data the spaced form. + +const ASSET_TYPE_LABELS = { + 'machine': 'Machines', + 'computer': 'Computers', + 'printer': 'Printers', + 'network_device': 'Network Devices', + 'network device': 'Network Devices', + 'measuring_tool': 'Measuring Tools', +} + +const ASSET_TYPE_ROUTES = { + 'machine': '/machines', + 'computer': '/pcs', + 'printer': '/printers', + 'network_device': '/network', + 'network device': '/network', + 'measuring_tool': '/measuringtools', +} + +// Plugin-specific id field inside asset.typedata for each asset type. +const ASSET_TYPE_ID_KEYS = { + 'machine': 'machineid', + 'computer': 'computerid', + 'printer': 'printerid', + 'network_device': 'networkdeviceid', + 'network device': 'networkdeviceid', + 'measuring_tool': 'measuringtoolid', +} + +function titleCase(text) { + return String(text || '') + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()) +} + +// Plural, human-friendly label for an asset type. Falls back to title-cased +// input so an unknown/new type still reads sensibly. +export function assetTypeLabel(type) { + if (!type) return type + return ASSET_TYPE_LABELS[String(type).toLowerCase()] || titleCase(type) +} + +// Base list route for an asset type (e.g. 'computer' -> '/pcs'). +export function assetTypeRoute(type) { + return ASSET_TYPE_ROUTES[String(type || '').toLowerCase()] || '/machines' +} + +// Full detail route for a unified-format asset, preferring the plugin-specific +// id in typedata and falling back to the asset id. +export function assetDetailRoute(asset) { + const type = (asset.assettype || '').toLowerCase() + const base = assetTypeRoute(type) + const idKey = ASSET_TYPE_ID_KEYS[type] + let id = asset.assetid + if (asset.typedata && idKey && asset.typedata[idKey]) { + id = asset.typedata[idKey] + } + return `${base}/${id}` +} diff --git a/frontend/src/utils/basePath.js b/frontend/src/utils/basePath.js new file mode 100644 index 00000000..c8e6318a --- /dev/null +++ b/frontend/src/utils/basePath.js @@ -0,0 +1,13 @@ +// Single source for the app's mount path. Vite injects import.meta.env.BASE_URL +// from the build-time `base` (default '/', or e.g. '/ops/' for a subpath IIS +// mount). Every root-absolute URL to a Flask-served asset or route must go +// through withBase() so it resolves under the mount instead of the server root. +export const BASE_URL = import.meta.env.BASE_URL + +// Prefix a root-absolute path (e.g. '/ge-aerospace-logo.svg', '/api', '/tv') +// with the mount base. Leaves full URLs (http, data:) untouched. +export function withBase(path) { + if (!path) return path + if (/^([a-z]+:)?\/\//i.test(path) || path.startsWith('data:')) return path + return BASE_URL + String(path).replace(/^\//, '') +} diff --git a/frontend/src/utils/colorStyle.js b/frontend/src/utils/colorStyle.js new file mode 100644 index 00000000..e2b76c2c --- /dev/null +++ b/frontend/src/utils/colorStyle.js @@ -0,0 +1,39 @@ +// Data-driven badge colors. A record stores a hex color; the UI renders it with +// an auto-picked readable text color, so any color stays legible. Pickers should +// offer PALETTE (curated, distinct, accessible) with custom hex as a fallback. + +// Curated categorical palette - distinct + accessible. ~12 is the practical +// ceiling for at-a-glance distinguishability, which is plenty per category. +export const PALETTE = [ + '#f5365c', // red + '#fb6340', // orange + '#ff8800', // amber + '#ffc107', // gold + '#2dce89', // green + '#04b962', // emerald + '#11cdef', // cyan + '#14abef', // blue + '#0d6efd', // royal blue + '#7934f3', // purple + '#e83e8c', // pink + '#6c757d', // gray +] + +// Black or white text for a given background, by perceived brightness. +export function readableText(bg) { + if (!bg || typeof bg !== 'string') return '#ffffff' + let hex = bg.trim().replace('#', '') + if (hex.length === 3) hex = hex.split('').map(c => c + c).join('') + if (hex.length !== 6) return '#ffffff' + const r = parseInt(hex.slice(0, 2), 16) + const g = parseInt(hex.slice(2, 4), 16) + const b = parseInt(hex.slice(4, 6), 16) + const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return brightness > 0.6 ? '#1a1a1a' : '#ffffff' +} + +// Style object for a badge/pill from a stored color (with a neutral fallback). +export function colorStyle(color, fallback = '#6c757d') { + const backgroundColor = color || fallback + return { backgroundColor, color: readableText(backgroundColor) } +} diff --git a/frontend/src/utils/mapColors.js b/frontend/src/utils/mapColors.js new file mode 100644 index 00000000..230aedb0 --- /dev/null +++ b/frontend/src/utils/mapColors.js @@ -0,0 +1,54 @@ +// Marker color logic for the shop-floor map, shared so the PDF export renders +// the exact same colors the map shows on screen. Mirrors the maps defined in +// ShopFloorMap.vue - keep the two in sync if the palette changes. + +export const assetTypeColorsMap = { + machine: '#F44336', // Red + computer: '#2196F3', // Blue + printer: '#4CAF50', // Green + 'network device': '#FF9800', // Orange + network_device: '#FF9800', // Orange (alternate key) + measuring_tool: '#9C27B0', // Purple + 'measuring tool': '#9C27B0' // Purple (normalized key) +} + +const DEFAULT_COLOR = '#BDBDBD' + +// Canonical form for comparing asset-type strings. The API sends the machine +// type as 'network_device' but subtype keys and labels use 'network device', +// so normalize underscores to spaces before any comparison. Without this, +// network-device subtypes silently fail to match (machine/computer/printer +// are single words and were unaffected, which is why only network broke). +export function normalizeAssetType(assettype) { + return (assettype || '').toLowerCase().replace(/_/g, ' ') +} + +// Color for an asset by its top-level asset type (used when no type filter is +// active, so every type is distinguished by color). +export function getAssetTypeColor(assettype) { + if (!assettype) return DEFAULT_COLOR + return assetTypeColorsMap[assettype.toLowerCase()] || DEFAULT_COLOR +} + +// The subtype id for an asset, read from the plugin-specific typedata block. +// Returns null when the asset has no subtype. +export function getSubtypeId(asset) { + if (!asset || !asset.typedata) return null + const typeLower = normalizeAssetType(asset.assettype) + if (typeLower === 'machine') return asset.typedata.machinetypeid + if (typeLower === 'computer') return asset.typedata.computertypeid + if (typeLower === 'network device') return asset.typedata.networkdevicetypeid + if (typeLower === 'printer') return asset.typedata.printertypeid + if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid + return null +} + +// Resolve the marker color for an asset the same way ShopFloorMap does: when a +// type is selected, color by subtype (grey fallback); otherwise by asset type. +export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) { + if (selectedType) { + const id = getSubtypeId(asset) + return (id != null && subtypeColors[id]) || DEFAULT_COLOR + } + return getAssetTypeColor(asset.assettype) +} diff --git a/frontend/src/utils/mapPdf.js b/frontend/src/utils/mapPdf.js new file mode 100644 index 00000000..8601eac8 --- /dev/null +++ b/frontend/src/utils/mapPdf.js @@ -0,0 +1,135 @@ +// Export the shop-floor map (current filtered assets on the facility blueprint) +// to a PDF, entirely client-side. The blueprint image is drawn full-size and +// each visible marker is placed at its scaled coordinate, so the PDF is a crisp +// vector-over-raster page rather than a screen capture. +import { jsPDF } from 'jspdf' +import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors' + +function loadImage(url) { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('Failed to load blueprint image: ' + url)) + img.src = url + }) +} + +function hexToRgb(hex) { + const h = (hex || '#BDBDBD').replace('#', '') + const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h + return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)] +} + +// Build the legend entries (color + label) for the assets present, matching the +// active coloring mode. +function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) { + const seen = new Map() + for (const a of assets) { + let key, label, color + if (selectedType) { + const id = getSubtypeId(a) + key = id != null ? String(id) : 'none' + label = (id != null && subtypeNames[id]) || 'Unspecified' + color = (id != null && subtypeColors[id]) || '#BDBDBD' + } else { + key = (a.assettype || 'unknown').toLowerCase() + label = a.assettype || 'Unknown' + color = getAssetTypeColor(a.assettype) + } + if (!seen.has(key)) seen.set(key, { label, color, count: 0 }) + seen.get(key).count += 1 + } + return [...seen.values()].sort((a, b) => b.count - a.count) +} + +export async function exportMapPdf(opts) { + const { + assets = [], + blueprintUrl, + mapWidth = 3300, + mapHeight = 2550, + selectedType = '', + subtypeColors = {}, + subtypeNames = {}, + filters = [], + facility = '', + title = 'Shop Floor Map' + } = opts + + const img = await loadImage(blueprintUrl) + + const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4', compress: true }) + const pageW = doc.internal.pageSize.getWidth() + const pageH = doc.internal.pageSize.getHeight() + const margin = 28 + + // ---- Header ---- + doc.setTextColor('#111111') + doc.setFont('helvetica', 'bold') + doc.setFontSize(16) + doc.text(title, margin, margin + 6) + + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + doc.setTextColor('#555555') + const stamp = new Date().toLocaleString() + const subParts = [facility, stamp, `${assets.length} asset${assets.length === 1 ? '' : 's'}`].filter(Boolean) + doc.text(subParts.join(' | '), margin, margin + 22) + + const filterText = filters.length ? 'Filters: ' + filters.join(' ') : 'Filters: none (all assets)' + doc.text(filterText, margin, margin + 35) + + // ---- Legend (wraps under the header, pushes the map down) ---- + const legend = buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) + const swatch = 8 + const legendY0 = margin + 50 + const lineH = 15 + let lx = margin + let ly = legendY0 + doc.setFontSize(8.5) + for (const entry of legend) { + const label = `${entry.label} (${entry.count})` + const w = swatch + 4 + doc.getTextWidth(label) + 16 + if (lx + w > pageW - margin) { lx = margin; ly += lineH } + const [r, g, b] = hexToRgb(entry.color) + doc.setFillColor(r, g, b) + doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4) + doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F') + doc.setTextColor('#333333') + doc.text(label, lx + swatch + 4, ly) + lx += w + } + const legendBottom = legend.length ? ly + 6 : legendY0 + + // ---- Blueprint image, fit into the area below the legend ---- + const aspect = mapWidth / mapHeight + const contentTop = legendBottom + 8 + const availW = pageW - margin * 2 + const availH = pageH - contentTop - margin + let imgW = availW + let imgH = availW / aspect + if (imgH > availH) { imgH = availH; imgW = availH * aspect } + const imgX = margin + (availW - imgW) / 2 + const imgY = contentTop + (availH - imgH) / 2 + + doc.addImage(img, 'PNG', imgX, imgY, imgW, imgH, undefined, 'FAST') + doc.setDrawColor('#cccccc'); doc.setLineWidth(0.5) + doc.rect(imgX, imgY, imgW, imgH) + + // ---- Markers (database Y is top-down, same origin as the drawn image) ---- + const radius = 3.2 + for (const a of assets) { + if (a.mapx == null || a.mapy == null) continue + const x = imgX + (a.mapx / mapWidth) * imgW + const y = imgY + (a.mapy / mapHeight) * imgH + if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue + const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors })) + doc.setFillColor(r, g, b) + doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6) + doc.circle(x, y, radius, 'FD') + } + + const dateSlug = new Date().toISOString().slice(0, 10) + doc.save(`shopfloor-map-${dateSlug}.pdf`) +} diff --git a/frontend/src/utils/qrTarget.js b/frontend/src/utils/qrTarget.js new file mode 100644 index 00000000..688e198b --- /dev/null +++ b/frontend/src/utils/qrTarget.js @@ -0,0 +1,26 @@ +// Shared QR target resolution for printed labels. Each label surface has a +// qr_target_ setting: blank means the QR links to the asset's own +// detail page on this instance; a non-blank value is treated as a URL +// template and every {placeholder} is substituted from the tokens map. +import { getSetting, getSiteBaseUrl } from './siteSettings' + +// Substitute {name} placeholders. Values are URL-encoded; unknown or empty +// placeholders collapse to an empty string rather than leaking the braces. +export function fillUrlTemplate(template, tokens) { + return template.replace(/\{([a-z0-9_]+)\}/gi, (match, name) => { + const value = tokens[name] + return (value === undefined || value === null) ? '' : encodeURIComponent(String(value)) + }) +} + +// Resolve the QR URL for one asset. settingKey is the qr_target_ +// setting, defaultPath the in-app detail route (e.g. /printers/12), tokens +// the placeholder values available to a custom template. +export async function buildQrUrl(settingKey, defaultPath, tokens = {}) { + const template = (await getSetting(settingKey, '')).trim() + if (!template) { + const baseUrl = await getSiteBaseUrl() + return `${baseUrl}${defaultPath}` + } + return fillUrlTemplate(template, tokens) +} diff --git a/frontend/src/utils/siteSettings.js b/frontend/src/utils/siteSettings.js new file mode 100644 index 00000000..22180db2 --- /dev/null +++ b/frontend/src/utils/siteSettings.js @@ -0,0 +1,169 @@ +// Shared read-through for public site settings (site_base_url, facility_name). +// The settings GET is public (jwt optional), so the kiosk dashboard and the +// print views can read these without auth. Fetched once and cached per page. +import { settingsApi } from '@/api' +import { withBase } from '@/utils/basePath' + +let settingsCache = null + +async function loadSettings() { + if (settingsCache) return settingsCache + try { + const response = await settingsApi.list() + const items = response.data?.data || response.data || [] + settingsCache = {} + for (const s of items) settingsCache[s.key] = s.value + } catch (err) { + console.error('Error loading site settings:', err) + settingsCache = {} + } + return settingsCache +} + +export async function getSetting(key, fallback = '') { + const settings = await loadSettings() + const value = settings[key] + return (value === undefined || value === null || value === '') ? fallback : value +} + +// Public base URL for QR codes / absolute links. Falls back to the current +// browsing origin when a site has not set one. +export async function getSiteBaseUrl() { + return getSetting('site_base_url', window.location.origin) +} + +// Facility name shown on the shopfloor dashboard. +export async function getFacilityName() { + return getSetting('facility_name', 'ShopDB') +} + +// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark. +export async function getSiteLogo() { + return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg')) +} + +// Logo composited into the center of printer QR codes. Empty = no overlay. +// Note: '' is a valid "no overlay" value, so read the raw setting rather than +// getSetting (which swaps '' for the fallback). +export async function getQrLogo() { + const settings = await loadSettings() + const value = settings['qr_logo'] + return withBase((value === undefined || value === null) ? '/ge-monogram.svg' : value) +} + +// Logo printed on machine inspection badges. +export async function getBadgeLogo() { + return withBase(await getSetting('badge_logo', '/ge-aerospace-logo.svg')) +} + +// Browser-tab favicon. Empty = keep the shipped /favicon.svg. +export async function getFavicon() { + const value = await getSetting('site_favicon', '') + return value ? withBase(value) : value +} + +// Brand primary color override. Empty = built-in palette from style.css. +export async function getBrandPrimaryColor() { + return getSetting('brand_primary_color', '') +} + +// Primary hover/active color (--primary-dark). Empty = derive from primary. +export async function getBrandPrimaryDarkColor() { + return getSetting('brand_primary_dark_color', '') +} + +// Accent color (--secondary). Empty = built-in palette from style.css. +export async function getBrandAccentColor() { + return getSetting('brand_accent_color', '') +} + +// Sidebar background color (--sidebar-bg). Empty = built-in palette. +export async function getBrandSidebarColor() { + return getSetting('brand_sidebar_color', '') +} + +// Darken a #rrggbb hex color by the given fraction (0.15 = 15% darker). +// Returns null for anything that is not a 6-digit hex so callers can skip it. +function darkenHexColor(hex, fraction) { + const match = /^#([0-9a-fA-F]{6})$/.exec(hex) + if (!match) return null + const num = parseInt(match[1], 16) + const scale = 1 - fraction + const red = Math.round(((num >> 16) & 0xff) * scale) + const green = Math.round(((num >> 8) & 0xff) * scale) + const blue = Math.round((num & 0xff) * scale) + const toHex = (channel) => channel.toString(16).padStart(2, '0') + return `#${toHex(red)}${toHex(green)}${toHex(blue)}` +} + +// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL +// templates, and the global-search URL template (all with a {ticket} +// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs. +export async function getServicenowUrls() { + const enabledRaw = await getSetting('servicenow_enabled', 'true') + const enabled = enabledRaw !== 'false' && enabledRaw !== '0' && enabledRaw !== false + const incidentUrl = await getSetting( + 'servicenow_incident_url', + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' + ) + const changeUrl = await getSetting( + 'servicenow_change_url', + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' + ) + const searchUrl = await getSetting( + 'servicenow_search_url', + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' + ) + return { enabled, incidentUrl, changeUrl, searchUrl } +} + +// Printer hostname template. {ip} is replaced with the dash-separated IP. +export async function getPrinterHostnameTemplate() { + return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') +} + +// Email domain appended to a contact SSO to build email/Teams links +// (sso@domain). Unset falls back to geaerospace.com; an explicit blank value +// means "disable contact action buttons", so read the raw setting rather than +// getSetting (which swaps '' for the fallback). +export async function getContactEmailDomain() { + const settings = await loadSettings() + const value = settings['contact_email_domain'] + return (value === undefined || value === null) ? 'geaerospace.com' : value +} + +// Apply per-site favicon + brand color at bootstrap. Empty settings keep the +// shipped defaults. Do not touch the style.css palette here. +export async function applyBranding() { + const favicon = await getFavicon() + if (favicon) { + let link = document.querySelector('link[rel="icon"]') + if (!link) { + link = document.createElement('link') + link.rel = 'icon' + document.head.appendChild(link) + } + link.href = favicon + } + const rootStyle = document.documentElement.style + const primaryColor = await getBrandPrimaryColor() + if (primaryColor) { + rootStyle.setProperty('--primary', primaryColor) + } + // Hover color: use the explicit value, else darken primary ~15%. + const primaryDarkColor = await getBrandPrimaryDarkColor() + if (primaryDarkColor) { + rootStyle.setProperty('--primary-dark', primaryDarkColor) + } else if (primaryColor) { + const derived = darkenHexColor(primaryColor, 0.15) + if (derived) rootStyle.setProperty('--primary-dark', derived) + } + const accentColor = await getBrandAccentColor() + if (accentColor) { + rootStyle.setProperty('--secondary', accentColor) + } + const sidebarColor = await getBrandSidebarColor() + if (sidebarColor) { + rootStyle.setProperty('--sidebar-bg', sidebarColor) + } +} diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue new file mode 100644 index 00000000..44e61ee1 --- /dev/null +++ b/frontend/src/views/AppLayout.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue new file mode 100644 index 00000000..cb2fc628 --- /dev/null +++ b/frontend/src/views/CalendarView.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/frontend/src/views/ChangePassword.vue b/frontend/src/views/ChangePassword.vue new file mode 100644 index 00000000..53273c5b --- /dev/null +++ b/frontend/src/views/ChangePassword.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 00000000..76bdfb60 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 00000000..c1f2f6cb --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/src/views/MapEditor.vue b/frontend/src/views/MapEditor.vue new file mode 100644 index 00000000..39330ffb --- /dev/null +++ b/frontend/src/views/MapEditor.vue @@ -0,0 +1,412 @@ + + + + + diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue new file mode 100644 index 00000000..88e2e5f2 --- /dev/null +++ b/frontend/src/views/MapView.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue new file mode 100644 index 00000000..3e7c5240 --- /dev/null +++ b/frontend/src/views/SearchResults.vue @@ -0,0 +1,479 @@ + + + + + diff --git a/frontend/src/views/SetupWizard.vue b/frontend/src/views/SetupWizard.vue new file mode 100644 index 00000000..8b142063 --- /dev/null +++ b/frontend/src/views/SetupWizard.vue @@ -0,0 +1,458 @@ + + + + + diff --git a/frontend/src/views/ShopfloorDashboard.vue b/frontend/src/views/ShopfloorDashboard.vue new file mode 100644 index 00000000..149ead8a --- /dev/null +++ b/frontend/src/views/ShopfloorDashboard.vue @@ -0,0 +1,762 @@ + + + + + diff --git a/frontend/src/views/TVDashboard.vue b/frontend/src/views/TVDashboard.vue new file mode 100644 index 00000000..16c4ff4e --- /dev/null +++ b/frontend/src/views/TVDashboard.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/views/applications/ApplicationDetail.vue b/frontend/src/views/applications/ApplicationDetail.vue new file mode 100644 index 00000000..1f93ab34 --- /dev/null +++ b/frontend/src/views/applications/ApplicationDetail.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/frontend/src/views/applications/ApplicationForm.vue b/frontend/src/views/applications/ApplicationForm.vue new file mode 100644 index 00000000..38f63315 --- /dev/null +++ b/frontend/src/views/applications/ApplicationForm.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/frontend/src/views/applications/ApplicationsList.vue b/frontend/src/views/applications/ApplicationsList.vue new file mode 100644 index 00000000..9ce783ad --- /dev/null +++ b/frontend/src/views/applications/ApplicationsList.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/frontend/src/views/employees/EmployeeDetail.vue b/frontend/src/views/employees/EmployeeDetail.vue new file mode 100644 index 00000000..76f55da7 --- /dev/null +++ b/frontend/src/views/employees/EmployeeDetail.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/frontend/src/views/geenforce/EnforcementReports.vue b/frontend/src/views/geenforce/EnforcementReports.vue new file mode 100644 index 00000000..a0aa0935 --- /dev/null +++ b/frontend/src/views/geenforce/EnforcementReports.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/views/geenforce/GeEnforceLayout.vue b/frontend/src/views/geenforce/GeEnforceLayout.vue new file mode 100644 index 00000000..b5c74af8 --- /dev/null +++ b/frontend/src/views/geenforce/GeEnforceLayout.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/frontend/src/views/geenforce/ManifestEditor.vue b/frontend/src/views/geenforce/ManifestEditor.vue new file mode 100644 index 00000000..05dd0dd4 --- /dev/null +++ b/frontend/src/views/geenforce/ManifestEditor.vue @@ -0,0 +1,1051 @@ + + + + + diff --git a/frontend/src/views/geenforce/entryForm.js b/frontend/src/views/geenforce/entryForm.js new file mode 100644 index 00000000..7384f639 --- /dev/null +++ b/frontend/src/views/geenforce/entryForm.js @@ -0,0 +1,183 @@ +// Pure, framework-free helpers for the GE-Enforce manifest editor. +// +// ManifestEditor.vue imports these directly (the component no longer keeps its +// own copies), so the unit tests in entryForm.spec.js exercise the shipped +// code path. Change the editor logic HERE. +// +// Everything here is a plain function of its inputs. No Vue, no reactivity, +// no network. That is the whole point - deterministic logic we can pin down. + +export const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry'] +export const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary'] +export const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile', + 'ValueMatches', 'pnputil', 'Always'] +export const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot'] + +// Plain-language description of each detection method, shown under the Detection +// method dropdown so a first-time site admin understands what "present" means +// for the method they picked. Key '' is the no-detection case. +export const DETECTION_METHOD_HINTS = { + '': 'No detection rule: the action runs every cycle.', + Registry: 'Already correct if the registry value at Detection path/name exists (and equals Detection value when one is set).', + File: 'Already correct if the file at Detection path exists.', + FileVersion: 'Already correct if the file at Detection path is at Detection value or newer. This target feeds the Compliance panel.', + Hash: 'Already correct if the file at Detection path matches the SHA256 in Detection value. Re-copies when the file changed.', + MarkerFile: 'Already correct if the marker file at Detection path exists. Installs once, then the marker suppresses reruns.', + ValueMatches: 'Already correct if the registry value at Detection path/name equals Detection value exactly.', + pnputil: 'Already correct if a driver matching Detection pattern is staged in the Windows driver store. For INF entries.', + Always: 'Never counts as present, so the action runs every cycle. Same effect as no detection rule.', +} + +// Description for the currently selected detection method, or '' if unknown. +export function detectionMethodHint(method) { + return DETECTION_METHOD_HINTS[method || ''] || '' +} + +// One blank entry form, matching the shape ManifestEditor seeds for a new entry. +export function blankEntry() { + return { Type: 'MSI', DetectionMethod: '', RegType: 'String', + inuseBehavior: '', inuseProcesses: [], appid: null, + PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false } +} + +// Split a comma list into trimmed non-empty parts. +export function splitList(value) { + return (value || '').split(',').map(item => item.trim()).filter(Boolean) +} + +// Turn an edit-form object into the API body sent to create/update an entry. +// +// Rules that matter (and that the specs pin): +// - appid is always present (null unlinks). It is shopdb metadata; the backend +// keeps it OFF the manifest JSON, so it is NOT one of the manifest scalars. +// - empty/undefined/null scalars are dropped. +// - Registry DWord/QWord values become real numbers; other reg types stay strings. +// - comma lists become arrays, and are dropped when empty. +// - preinstall flags only appear when truthy. +// - InUseCheck carries per-process Name, optional ExePath, optional numeric timeout. +export function buildEntryPayload(form) { + // appid is shopdb metadata, always sent (null unlinks); the backend keeps it + // off the manifest JSON. + const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null } + const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source', + 'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath', + 'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion', + 'LogFile', 'ApplyMode', 'UpdateWindow', '_comment'] + for (const key of scalars) { + if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key] + } + if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod + if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec + if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') { + out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue + } + const pctypes = splitList(form.PCTypes) + if (pctypes.length) out.PCTypes = pctypes + const hostnames = splitList(form.TargetHostnames) + if (hostnames.length) out.TargetHostnames = hostnames + const machinenumbers = splitList(form.TargetMachineNumbers) + if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers + for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) { + if (form[flag]) out[flag] = true + } + if (form.inuseBehavior) { + out.InUseCheck = { + Behavior: form.inuseBehavior, + Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => { + const processData = { Name: process.name } + if (process.exepath) processData.ExePath = process.exepath + if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) { + processData.GracefulCloseTimeoutSec = Number(process.timeout) + } + return processData + }), + } + } + return out +} + +// Which entry types the type dropdown offers, given the scope phase. Preinstall +// supports MSI/EXE only, but keeps a current out-of-set value visible. +export function availableEntryTypes(isPreinstall, currentType) { + if (!isPreinstall) return ENTRY_TYPES + const allowed = ['MSI', 'EXE'] + return currentType && !allowed.includes(currentType) ? [...allowed, currentType] : allowed +} + +// Which detection methods the dropdown offers. Preinstall supports Registry/File +// only, but keeps a current out-of-set value visible. +export function availableDetectionMethods(isPreinstall, currentMethod) { + if (!isPreinstall) return DETECTION_METHODS + const allowed = ['Registry', 'File'] + return currentMethod && !allowed.includes(currentMethod) ? [...allowed, currentMethod] : allowed +} + +// Which targeting gates a scope actually surfaces by default (before "Show all"). +export function targetingGates(scope) { + if (!scope) { + return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false } + } + const name = (scope.scopename || '').toLowerCase() + const entries = scope.entries || [] + const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common' + return { + pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length), + cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion), + // Data-driven (a loose name match would wrongly flag 'nocollections'). + machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length), + hostnames: entries.some(e => (e.TargetHostnames || []).length), + } +} + +// The contextual hint under the Targeting header. +export function targetingHint(scope) { + if (!scope) return '' + if (scope.iscommon || scope.scopename === 'common') { + return 'Fleet-wide manifest: use PC types to target which types get this entry.' + } + if (scope.phase === 'preinstall') { + return 'Preinstall manifest: use PC types to target; preinstall flags apply here.' + } + return `This manifest already runs only on ${scope.scopename} PCs. ` + + 'Targeting below narrows within that (version, bay, or subtype).' +} + +// One-line summary of what a scope installs (installer entries only). +export function scopeSummary(scope) { + if (!scope || !scope.entries || !scope.entries.length) return '' + const installers = scope.entries + .filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type)) + .map(e => e.Name) + const shown = installers.slice(0, 6).join(', ') + const more = installers.length > 6 ? `, +${installers.length - 6} more` : '' + const apps = installers.length ? `Installs ${shown}${more}. ` : '' + return `${apps}${scope.entries.length} entries; runs after common.` +} + +// Verb table for describeEntry. +export const ACTION_VERB = { + MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs', + PS1: 'Runs a script for', INF: 'Installs a driver for', + File: 'Copies a file for', Registry: 'Sets a registry value for', +} + +// Plain-English one-liner: what an entry does, when it self-heals, who it hits. +export function describeEntry(entry) { + let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}` + const method = entry.DetectionMethod + if (!method || method === 'Always') text += '; runs every cycle' + else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}` + else if (method === 'Hash') text += '; re-copies if the file changed' + else if (method === 'MarkerFile') text += '; installs once' + else text += '; reinstalls if not detected' + if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only` + else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) { + text += `; ${entry.TargetMachineNumbers.length} specific bay(s)` + } else if (entry.TargetHostnames && entry.TargetHostnames.length) { + text += '; specific hostname(s)' + } else if (entry.PCTypes && entry.PCTypes.length) { + text += `; ${entry.PCTypes.length} PC type(s)` + } + if (entry.appname) text += `; tracked: ${entry.appname}` + return text +} diff --git a/frontend/src/views/geenforce/entryForm.spec.js b/frontend/src/views/geenforce/entryForm.spec.js new file mode 100644 index 00000000..bd299230 --- /dev/null +++ b/frontend/src/views/geenforce/entryForm.spec.js @@ -0,0 +1,321 @@ +import { describe, it, expect } from 'vitest' +import { + blankEntry, + splitList, + buildEntryPayload, + availableEntryTypes, + availableDetectionMethods, + targetingGates, + targetingHint, + scopeSummary, + describeEntry, + detectionMethodHint, + ENTRY_TYPES, + DETECTION_METHODS, +} from './entryForm.js' + +// These specs pin the deterministic logic that turns the manifest editor form +// into an API body, and the various display helpers. This is the code most +// likely to silently regress (numeric coercion, dropped-vs-kept fields, the +// appid-must-never-be-a-manifest-scalar rule). + +describe('splitList', () => { + it('trims, drops empties, and returns an array', () => { + expect(splitList('a, b ,, c ')).toEqual(['a', 'b', 'c']) + }) + it('returns an empty array for null/empty', () => { + expect(splitList('')).toEqual([]) + expect(splitList(null)).toEqual([]) + expect(splitList(undefined)).toEqual([]) + }) +}) + +describe('buildEntryPayload - appid handling', () => { + it('always emits appid as a top-level key, defaulting to null', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI' }) + expect(out.appid).toBeNull() + expect('appid' in out).toBe(true) + }) + it('passes a linked appid through unchanged', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI', appid: 41 }) + expect(out.appid).toBe(41) + }) + it('never lets appid become a manifest scalar (it is metadata, not a manifest key)', () => { + // appid lives at the top level as metadata; it must not appear nested in + // any manifest sub-structure. Guard against a future refactor smuggling it + // into e.g. InUseCheck or a detection block. + const out = buildEntryPayload({ + Name: 'x', Type: 'Registry', appid: 7, RegType: 'DWord', RegValue: '1', + DetectionMethod: 'Registry', inuseBehavior: 'ForceClose', + inuseProcesses: [{ name: 'p', exepath: '', timeout: null }], + }) + // Only the top-level appid key carries it. + const serialized = JSON.stringify({ ...out, appid: undefined }) + expect(serialized.includes('appid')).toBe(false) + expect(serialized.toLowerCase().includes('"appid"')).toBe(false) + }) +}) + +describe('buildEntryPayload - scalar filtering', () => { + it('drops empty-string, null, and undefined scalars', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', Installer: '', InstallArgs: null, + Script: undefined, LogFile: 'setup.log', + }) + expect('Installer' in out).toBe(false) + expect('InstallArgs' in out).toBe(false) + expect('Script' in out).toBe(false) + expect(out.LogFile).toBe('setup.log') + }) + it('keeps the underscore-prefixed scalars (_CmmVersion, _comment)', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', _CmmVersion: '2019', _comment: 'note', + }) + expect(out._CmmVersion).toBe('2019') + expect(out._comment).toBe('note') + }) + it('only emits DetectionMethod when set', () => { + expect('DetectionMethod' in buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: '' })).toBe(false) + expect(buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: 'FileVersion' }).DetectionMethod).toBe('FileVersion') + }) + it('drops a zero/falsy WaitTimeoutSec but keeps a real one', () => { + expect('WaitTimeoutSec' in buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 0 })).toBe(false) + expect(buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 300 }).WaitTimeoutSec).toBe(300) + }) +}) + +describe('buildEntryPayload - RegValue coercion', () => { + it('coerces DWord to a Number', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '1' }) + expect(out.RegValue).toBe(1) + expect(typeof out.RegValue).toBe('number') + }) + it('coerces QWord to a Number', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'QWord', RegValue: '42' }) + expect(out.RegValue).toBe(42) + }) + it('leaves String reg values as strings', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'String', RegValue: 'hello' }) + expect(out.RegValue).toBe('hello') + expect(typeof out.RegValue).toBe('string') + }) + it('does not emit RegValue for a non-Registry type', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI', RegType: 'DWord', RegValue: '1' }) + expect('RegValue' in out).toBe(false) + }) + it('drops an empty RegValue even for Registry', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '' }) + expect('RegValue' in out).toBe(false) + }) +}) + +describe('buildEntryPayload - comma lists to arrays', () => { + it('splits PCTypes / TargetHostnames / TargetMachineNumbers', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', + PCTypes: 'gea-shopfloor-cmm, gea-shopfloor-collections', + TargetHostnames: 'host-a, host-b', + TargetMachineNumbers: '0101, 0102', + }) + expect(out.PCTypes).toEqual(['gea-shopfloor-cmm', 'gea-shopfloor-collections']) + expect(out.TargetHostnames).toEqual(['host-a', 'host-b']) + expect(out.TargetMachineNumbers).toEqual(['0101', '0102']) + }) + it('omits the array keys when the list is empty', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PCTypes: '', TargetHostnames: ' ' }) + expect('PCTypes' in out).toBe(false) + expect('TargetHostnames' in out).toBe(false) + }) +}) + +describe('buildEntryPayload - preinstall flags', () => { + it('emits only the truthy flags', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', + PreEnrollment: true, KillAfterDetection: false, PCTypesStrict: true, + }) + expect(out.PreEnrollment).toBe(true) + expect(out.PCTypesStrict).toBe(true) + expect('KillAfterDetection' in out).toBe(false) + }) +}) + +describe('buildEntryPayload - InUseCheck processes', () => { + it('carries Name, optional ExePath, and a numeric timeout per process', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', inuseBehavior: 'CloseAndReopen', + inuseProcesses: [ + { name: 'pcdmis', exepath: 'C:\\pcd\\pcdmis.exe', timeout: 30 }, + { name: 'nodmis', exepath: '', timeout: null }, + ], + }) + expect(out.InUseCheck.Behavior).toBe('CloseAndReopen') + expect(out.InUseCheck.Processes).toEqual([ + { Name: 'pcdmis', ExePath: 'C:\\pcd\\pcdmis.exe', GracefulCloseTimeoutSec: 30 }, + { Name: 'nodmis' }, + ]) + }) + it('drops processes with no name', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', inuseBehavior: 'ForceClose', + inuseProcesses: [{ name: '', exepath: 'x', timeout: 5 }, { name: 'keep', timeout: 5 }], + }) + expect(out.InUseCheck.Processes).toEqual([{ Name: 'keep', GracefulCloseTimeoutSec: 5 }]) + }) + it('coerces a string timeout to a number', () => { + const out = buildEntryPayload({ + Name: 'x', Type: 'MSI', inuseBehavior: 'Defer', + inuseProcesses: [{ name: 'p', exepath: '', timeout: '15' }], + }) + expect(out.InUseCheck.Processes[0].GracefulCloseTimeoutSec).toBe(15) + }) + it('emits no InUseCheck when there is no behavior', () => { + const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: '' }) + expect('InUseCheck' in out).toBe(false) + }) +}) + +describe('buildEntryPayload - a blank new entry round-trips', () => { + it('produces a minimal body from blankEntry (name/type + null appid + default RegType)', () => { + const out = buildEntryPayload({ ...blankEntry(), Name: 'Fresh' }) + // Type MSI, appid null. RegType is default String but non-Registry, so it + // is still carried as a scalar (it is in the scalar list). Confirm the shape. + expect(out.Name).toBe('Fresh') + expect(out.Type).toBe('MSI') + expect(out.appid).toBeNull() + expect('InUseCheck' in out).toBe(false) + expect('PCTypes' in out).toBe(false) + }) +}) + +describe('availableEntryTypes', () => { + it('returns the full list for a non-preinstall scope', () => { + expect(availableEntryTypes(false, 'File')).toEqual(ENTRY_TYPES) + }) + it('restricts to MSI/EXE for preinstall', () => { + expect(availableEntryTypes(true, 'MSI')).toEqual(['MSI', 'EXE']) + }) + it('keeps an out-of-set current value visible in preinstall', () => { + expect(availableEntryTypes(true, 'Registry')).toEqual(['MSI', 'EXE', 'Registry']) + }) +}) + +describe('availableDetectionMethods', () => { + it('returns the full list for a non-preinstall scope', () => { + expect(availableDetectionMethods(false, 'Hash')).toEqual(DETECTION_METHODS) + }) + it('restricts to Registry/File for preinstall', () => { + expect(availableDetectionMethods(true, 'File')).toEqual(['Registry', 'File']) + }) + it('keeps an out-of-set current value visible in preinstall', () => { + expect(availableDetectionMethods(true, 'FileVersion')).toEqual(['Registry', 'File', 'FileVersion']) + }) +}) + +describe('targetingGates', () => { + it('defaults to pctypes-only when there is no scope', () => { + expect(targetingGates(null)).toEqual({ + pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false, + }) + }) + it('shows pctypes for a fleetwide/common/preinstall scope', () => { + expect(targetingGates({ scopename: 'common', entries: [] }).pctypes).toBe(true) + expect(targetingGates({ scopename: 'x', phase: 'preinstall', entries: [] }).pctypes).toBe(true) + expect(targetingGates({ scopename: 'x', iscommon: true, entries: [] }).pctypes).toBe(true) + }) + it('shows the cmm gate for a cmm-named scope', () => { + expect(targetingGates({ scopename: 'gea-shopfloor-cmm', entries: [] }).cmmversion).toBe(true) + }) + it('does not name-match machinenumbers (data-driven only)', () => { + // 'nocollections' must not trip a loose name match; machinenumbers is purely + // data-driven off the entries. + const gates = targetingGates({ scopename: 'nocollections', entries: [] }) + expect(gates.machinenumbers).toBe(false) + }) + it('surfaces gates from entry data', () => { + const gates = targetingGates({ + scopename: 'per-type', entries: [ + { PCTypes: ['a'] }, { TargetMachineNumbers: ['0101'] }, { TargetHostnames: ['h'] }, + ], + }) + expect(gates.pctypes).toBe(true) + expect(gates.machinenumbers).toBe(true) + expect(gates.hostnames).toBe(true) + }) +}) + +describe('targetingHint', () => { + it('is empty with no scope', () => { + expect(targetingHint(null)).toBe('') + }) + it('describes a fleet-wide common manifest', () => { + expect(targetingHint({ scopename: 'common' })).toMatch(/Fleet-wide/) + }) + it('describes a preinstall manifest', () => { + expect(targetingHint({ scopename: 'x', phase: 'preinstall' })).toMatch(/Preinstall/) + }) + it('names a per-type scope', () => { + expect(targetingHint({ scopename: 'gea-shopfloor-cmm' })).toMatch(/gea-shopfloor-cmm/) + }) +}) + +describe('scopeSummary', () => { + it('is empty with no entries', () => { + expect(scopeSummary({ entries: [] })).toBe('') + expect(scopeSummary(null)).toBe('') + }) + it('lists installer names and the entry count', () => { + const summary = scopeSummary({ + entries: [ + { Type: 'MSI', Name: 'eDNC' }, + { Type: 'Registry', Name: 'reg' }, + ], + }) + expect(summary).toBe('Installs eDNC. 2 entries; runs after common.') + }) + it('truncates past six installers with a +N more', () => { + const entries = Array.from({ length: 8 }, (_, i) => ({ Type: 'MSI', Name: `app${i}` })) + const summary = scopeSummary({ entries }) + expect(summary).toMatch(/\+2 more/) + }) +}) + +describe('describeEntry', () => { + it('describes an MSI with FileVersion detection using the expected version', () => { + const text = describeEntry({ Type: 'MSI', Name: 'PC-DMIS', DetectionMethod: 'FileVersion', DetectionValue: '2019 R1' }) + expect(text).toBe('Installs PC-DMIS; reinstalls unless version is 2019 R1') + }) + it('says runs every cycle for Always / no detection', () => { + expect(describeEntry({ Type: 'CMD', Name: 'x' })).toBe('Runs x; runs every cycle') + expect(describeEntry({ Type: 'CMD', Name: 'x', DetectionMethod: 'Always' })).toBe('Runs x; runs every cycle') + }) + it('appends a CMM gate note', () => { + const text = describeEntry({ Type: 'MSI', Name: 'x', _CmmVersion: '2016' }) + expect(text).toMatch(/CMM 2016 bays only/) + }) + it('appends the tracked app name when present', () => { + const text = describeEntry({ Type: 'MSI', Name: 'x', appname: 'PC-DMIS' }) + expect(text).toMatch(/tracked: PC-DMIS/) + }) + it('falls back to Applies for an unknown type', () => { + expect(describeEntry({ Type: 'Weird', Name: 'x' })).toMatch(/^Applies x/) + }) +}) + +describe('detectionMethodHint', () => { + it('has a non-empty hint for every detection method', () => { + for (const method of DETECTION_METHODS) { + expect(detectionMethodHint(method).length).toBeGreaterThan(0) + } + }) + it('describes the no-detection case for empty/undefined', () => { + expect(detectionMethodHint('')).toMatch(/every cycle/) + expect(detectionMethodHint(undefined)).toMatch(/every cycle/) + }) + it('ties FileVersion to the compliance panel', () => { + expect(detectionMethodHint('FileVersion')).toMatch(/Compliance/) + }) + it('returns empty string for an unknown method', () => { + expect(detectionMethodHint('Nonsense')).toBe('') + }) +}) diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue b/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue new file mode 100644 index 00000000..d3f7f0e6 --- /dev/null +++ b/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseForm.vue b/frontend/src/views/knowledgebase/KnowledgeBaseForm.vue new file mode 100644 index 00000000..2726e4a2 --- /dev/null +++ b/frontend/src/views/knowledgebase/KnowledgeBaseForm.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseList.vue b/frontend/src/views/knowledgebase/KnowledgeBaseList.vue new file mode 100644 index 00000000..fd20ebde --- /dev/null +++ b/frontend/src/views/knowledgebase/KnowledgeBaseList.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/frontend/src/views/machines/MachineDetail.vue b/frontend/src/views/machines/MachineDetail.vue new file mode 100644 index 00000000..566197a2 --- /dev/null +++ b/frontend/src/views/machines/MachineDetail.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/frontend/src/views/machines/MachineForm.vue b/frontend/src/views/machines/MachineForm.vue new file mode 100644 index 00000000..7b9c209e --- /dev/null +++ b/frontend/src/views/machines/MachineForm.vue @@ -0,0 +1,706 @@ + + + + + diff --git a/frontend/src/views/machines/MachinesList.vue b/frontend/src/views/machines/MachinesList.vue new file mode 100644 index 00000000..b31db8fe --- /dev/null +++ b/frontend/src/views/machines/MachinesList.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/src/views/measuringtools/MeasuringToolDetail.vue b/frontend/src/views/measuringtools/MeasuringToolDetail.vue new file mode 100644 index 00000000..a227b954 --- /dev/null +++ b/frontend/src/views/measuringtools/MeasuringToolDetail.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/frontend/src/views/measuringtools/MeasuringToolForm.vue b/frontend/src/views/measuringtools/MeasuringToolForm.vue new file mode 100644 index 00000000..5af746b3 --- /dev/null +++ b/frontend/src/views/measuringtools/MeasuringToolForm.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/frontend/src/views/measuringtools/MeasuringToolsList.vue b/frontend/src/views/measuringtools/MeasuringToolsList.vue new file mode 100644 index 00000000..32cf342a --- /dev/null +++ b/frontend/src/views/measuringtools/MeasuringToolsList.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/frontend/src/views/network/NetworkDeviceDetail.vue b/frontend/src/views/network/NetworkDeviceDetail.vue new file mode 100644 index 00000000..9a1ec717 --- /dev/null +++ b/frontend/src/views/network/NetworkDeviceDetail.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/frontend/src/views/network/NetworkDeviceForm.vue b/frontend/src/views/network/NetworkDeviceForm.vue new file mode 100644 index 00000000..b3a0a8c3 --- /dev/null +++ b/frontend/src/views/network/NetworkDeviceForm.vue @@ -0,0 +1,552 @@ + + + + + diff --git a/frontend/src/views/network/NetworkDevicesList.vue b/frontend/src/views/network/NetworkDevicesList.vue new file mode 100644 index 00000000..e528ecfc --- /dev/null +++ b/frontend/src/views/network/NetworkDevicesList.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/frontend/src/views/network/NetworkHub.vue b/frontend/src/views/network/NetworkHub.vue new file mode 100644 index 00000000..2a9015f9 --- /dev/null +++ b/frontend/src/views/network/NetworkHub.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/views/network/SubnetDetail.vue b/frontend/src/views/network/SubnetDetail.vue new file mode 100644 index 00000000..6975044c --- /dev/null +++ b/frontend/src/views/network/SubnetDetail.vue @@ -0,0 +1,88 @@ + + + diff --git a/frontend/src/views/network/SubnetsBrowse.vue b/frontend/src/views/network/SubnetsBrowse.vue new file mode 100644 index 00000000..ad2fa4ed --- /dev/null +++ b/frontend/src/views/network/SubnetsBrowse.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/views/notifications/NotificationForm.vue b/frontend/src/views/notifications/NotificationForm.vue new file mode 100644 index 00000000..d985791b --- /dev/null +++ b/frontend/src/views/notifications/NotificationForm.vue @@ -0,0 +1,642 @@ + + + + + diff --git a/frontend/src/views/notifications/NotificationTypesList.vue b/frontend/src/views/notifications/NotificationTypesList.vue new file mode 100644 index 00000000..6d477774 --- /dev/null +++ b/frontend/src/views/notifications/NotificationTypesList.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/frontend/src/views/notifications/NotificationsList.vue b/frontend/src/views/notifications/NotificationsList.vue new file mode 100644 index 00000000..7540c476 --- /dev/null +++ b/frontend/src/views/notifications/NotificationsList.vue @@ -0,0 +1,175 @@ + + + diff --git a/frontend/src/views/pcs/PCDetail.vue b/frontend/src/views/pcs/PCDetail.vue new file mode 100644 index 00000000..b7c501a1 --- /dev/null +++ b/frontend/src/views/pcs/PCDetail.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/frontend/src/views/pcs/PCForm.vue b/frontend/src/views/pcs/PCForm.vue new file mode 100644 index 00000000..f42bb7d5 --- /dev/null +++ b/frontend/src/views/pcs/PCForm.vue @@ -0,0 +1,609 @@ + + + + + diff --git a/frontend/src/views/pcs/PCsList.vue b/frontend/src/views/pcs/PCsList.vue new file mode 100644 index 00000000..3c05e742 --- /dev/null +++ b/frontend/src/views/pcs/PCsList.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/frontend/src/views/print/AssetLabel.vue b/frontend/src/views/print/AssetLabel.vue new file mode 100644 index 00000000..53760d4e --- /dev/null +++ b/frontend/src/views/print/AssetLabel.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/frontend/src/views/print/AssetLabelBatch.vue b/frontend/src/views/print/AssetLabelBatch.vue new file mode 100644 index 00000000..c02bb16a --- /dev/null +++ b/frontend/src/views/print/AssetLabelBatch.vue @@ -0,0 +1,501 @@ + + + + + diff --git a/frontend/src/views/print/MachineBadge.vue b/frontend/src/views/print/MachineBadge.vue new file mode 100644 index 00000000..ddf0f860 --- /dev/null +++ b/frontend/src/views/print/MachineBadge.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/frontend/src/views/print/PrinterQRBatch.vue b/frontend/src/views/print/PrinterQRBatch.vue new file mode 100644 index 00000000..5877b250 --- /dev/null +++ b/frontend/src/views/print/PrinterQRBatch.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/frontend/src/views/print/PrinterQRSingle.vue b/frontend/src/views/print/PrinterQRSingle.vue new file mode 100644 index 00000000..b87a6603 --- /dev/null +++ b/frontend/src/views/print/PrinterQRSingle.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/frontend/src/views/print/USBLabelBatch.vue b/frontend/src/views/print/USBLabelBatch.vue new file mode 100644 index 00000000..ef8ece44 --- /dev/null +++ b/frontend/src/views/print/USBLabelBatch.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/frontend/src/views/print/assetLabel.js b/frontend/src/views/print/assetLabel.js new file mode 100644 index 00000000..75b861cf --- /dev/null +++ b/frontend/src/views/print/assetLabel.js @@ -0,0 +1,99 @@ +// Shared wiring for the asset label pages (single AssetLabel.vue and batch +// AssetLabelBatch.vue): per-type api/route config plus the "what does the code +// encode" resolution so both surfaces behave identically. +import { + machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi, +} from '../../api' +import { fillUrlTemplate } from '@/utils/qrTarget' +import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings' + +// Per asset-type: which api loads it, its detail/list routes, the extension key +// nested in the merged payload, and its qr_target_ settings key. +export const TYPE_CONFIG = { + machine: { + api: machinesApi, extkey: 'machine', targetKey: 'qr_target_machine', + label: 'Machine', detailPath: id => `/machines/${id}`, listPath: '/machines', + }, + computer: { + api: computersApi, extkey: 'computer', targetKey: 'qr_target_computer', + label: 'Computer', detailPath: id => `/pcs/${id}`, listPath: '/pcs', + }, + printer: { + api: printersApi, extkey: 'printer', targetKey: 'qr_target_printer', + label: 'Printer', detailPath: id => `/printers/${id}`, listPath: '/printers', + }, + network_device: { + api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device', + label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network', + }, + measuring_tool: { + api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool', + label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools', + }, +} + +// Hardcoded fallback default per type when the site setting is unset. Machines +// encode their machine number, measuring tools their inspection location. +const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' } + +export function hasLocationType(assettype) { + return assettype === 'measuring_tool' +} + +// The default encode mode for a type: the label_default_encodes_ site +// setting when valid, else the hardcoded fallback. +export async function resolveDefaultEncodes(assettype) { + const valid = ['assetpage', 'assetnumber', 'serialnumber', 'custom'] + if (hasLocationType(assettype)) valid.push('location') + const seeded = await getSetting(`label_default_encodes_${assettype}`, '') + return valid.includes(seeded) ? seeded : (DEFAULT_ENCODES[assettype] || 'assetpage') +} + +// 'location' degrades to the asset page when the asset has no location code. +export function effectiveEncodes(encodes, asset) { + if (encodes === 'location' && !asset?.locationcode) return 'assetpage' + return encodes +} + +function tokensFor(asset) { + return { + assetid: asset.assetid || '', + assetnumber: asset.assetnumber || '', + serialnumber: asset.serialnumber || '', + name: asset.name || '', + pluginid: asset.pluginid || '', + locationcode: asset.locationcode || '', + locationname: asset.locationname || '', + } +} + +// The string a label's code encodes for one asset. Empty when the chosen field +// has no value (e.g. serial number on an asset with none). +export async function resolveCodeText(assettype, asset, encodes) { + const config = TYPE_CONFIG[assettype] + if (!config || !asset) return '' + const detailId = asset.pluginid + const assetPageUrl = `${await getSiteBaseUrl()}${config.detailPath(detailId)}` + switch (effectiveEncodes(encodes, asset)) { + case 'assetnumber': return asset.assetnumber || '' + case 'serialnumber': return asset.serialnumber || '' + case 'location': return asset.locationcode || '' + case 'custom': { + const template = (await getSetting(config.targetKey, '')).trim() + return template ? fillUrlTemplate(template, tokensFor(asset)) : assetPageUrl + } + case 'assetpage': + default: return assetPageUrl + } +} + +// Human caption printed under the code. +export function captionFor(asset, encodes) { + if (!asset) return '' + switch (effectiveEncodes(encodes, asset)) { + case 'location': return asset.locationcode || '' + case 'serialnumber': return asset.serialnumber || '' + case 'assetnumber': + default: return asset.assetnumber || '' + } +} diff --git a/frontend/src/views/print/qrLogo.js b/frontend/src/views/print/qrLogo.js new file mode 100644 index 00000000..8d865fc6 --- /dev/null +++ b/frontend/src/views/print/qrLogo.js @@ -0,0 +1,62 @@ +// 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' +import { getQrLogo } from '@/utils/siteSettings' + +const GE_LOGO_SVG = `` + +let logoImage = null + +// Load the configured QR overlay image. On any load failure, fall back to the +// built-in GE monogram (last-resort constant above). +function loadLogo(url) { + if (logoImage) return Promise.resolve(logoImage) + return new Promise(resolve => { + const img = new Image() + img.onload = () => { logoImage = img; resolve(img) } + img.onerror = () => { + const fallback = new Image() + fallback.onload = () => { logoImage = fallback; resolve(fallback) } + fallback.onerror = () => resolve(null) + fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG) + } + img.src = url + }) +} + +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, x, y, logoSize, logoSize) + } +} + +// Render a QR for `url` to a PNG data URL with the configured logo composited +// in the center. Empty qr_logo setting = no overlay. Returns '' on failure. +export async function renderQrDataUrl(url) { + const qrLogoUrl = await getQrLogo() + const canvas = document.createElement('canvas') + try { + await QRCode.toCanvas(canvas, url, { width: 144, margin: 0, errorCorrectionLevel: 'H' }) + if (qrLogoUrl) { + const logo = await loadLogo(qrLogoUrl) + drawLogoOverlay(canvas, logo) + } + return canvas.toDataURL('image/png') + } catch (err) { + console.error('QR error:', err) + return '' + } +} diff --git a/frontend/src/views/printers/PrinterDetail.vue b/frontend/src/views/printers/PrinterDetail.vue new file mode 100644 index 00000000..62f9a9bc --- /dev/null +++ b/frontend/src/views/printers/PrinterDetail.vue @@ -0,0 +1,457 @@ + + + + + diff --git a/frontend/src/views/printers/PrinterForm.vue b/frontend/src/views/printers/PrinterForm.vue new file mode 100644 index 00000000..906ad70d --- /dev/null +++ b/frontend/src/views/printers/PrinterForm.vue @@ -0,0 +1,655 @@ + + + + + diff --git a/frontend/src/views/printers/PrintersList.vue b/frontend/src/views/printers/PrintersList.vue new file mode 100644 index 00000000..f64acf08 --- /dev/null +++ b/frontend/src/views/printers/PrintersList.vue @@ -0,0 +1,167 @@ + + + diff --git a/frontend/src/views/reports/CalibrationReport.vue b/frontend/src/views/reports/CalibrationReport.vue new file mode 100644 index 00000000..79d95b02 --- /dev/null +++ b/frontend/src/views/reports/CalibrationReport.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/views/reports/PCRelationshipsReport.vue b/frontend/src/views/reports/PCRelationshipsReport.vue new file mode 100644 index 00000000..9cc0862f --- /dev/null +++ b/frontend/src/views/reports/PCRelationshipsReport.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/views/reports/ReportsIndex.vue b/frontend/src/views/reports/ReportsIndex.vue new file mode 100644 index 00000000..c2a5ccc3 --- /dev/null +++ b/frontend/src/views/reports/ReportsIndex.vue @@ -0,0 +1,514 @@ + + + + + diff --git a/frontend/src/views/reports/TonerReport.vue b/frontend/src/views/reports/TonerReport.vue new file mode 100644 index 00000000..95174275 --- /dev/null +++ b/frontend/src/views/reports/TonerReport.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/frontend/src/views/reports/WarrantyReport.vue b/frontend/src/views/reports/WarrantyReport.vue new file mode 100644 index 00000000..e4c98f60 --- /dev/null +++ b/frontend/src/views/reports/WarrantyReport.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/views/settings/AccessProtocolsList.vue b/frontend/src/views/settings/AccessProtocolsList.vue new file mode 100644 index 00000000..ec4b71bb --- /dev/null +++ b/frontend/src/views/settings/AccessProtocolsList.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/frontend/src/views/settings/ApiTokensList.vue b/frontend/src/views/settings/ApiTokensList.vue new file mode 100644 index 00000000..ea34cbd3 --- /dev/null +++ b/frontend/src/views/settings/ApiTokensList.vue @@ -0,0 +1,551 @@ + + + + + diff --git a/frontend/src/views/settings/AssetIdentifiersSettings.vue b/frontend/src/views/settings/AssetIdentifiersSettings.vue new file mode 100644 index 00000000..2059a812 --- /dev/null +++ b/frontend/src/views/settings/AssetIdentifiersSettings.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/views/settings/AssetTypesList.vue b/frontend/src/views/settings/AssetTypesList.vue new file mode 100644 index 00000000..60677999 --- /dev/null +++ b/frontend/src/views/settings/AssetTypesList.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/views/settings/AuditLogs.vue b/frontend/src/views/settings/AuditLogs.vue new file mode 100644 index 00000000..043a5488 --- /dev/null +++ b/frontend/src/views/settings/AuditLogs.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/frontend/src/views/settings/AuditSettings.vue b/frontend/src/views/settings/AuditSettings.vue new file mode 100644 index 00000000..7831da07 --- /dev/null +++ b/frontend/src/views/settings/AuditSettings.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/views/settings/AuthenticationSettings.vue b/frontend/src/views/settings/AuthenticationSettings.vue new file mode 100644 index 00000000..4bc673ab --- /dev/null +++ b/frontend/src/views/settings/AuthenticationSettings.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/views/settings/BrandingSettings.vue b/frontend/src/views/settings/BrandingSettings.vue new file mode 100644 index 00000000..bf455bc2 --- /dev/null +++ b/frontend/src/views/settings/BrandingSettings.vue @@ -0,0 +1,142 @@ + + + diff --git a/frontend/src/views/settings/BusinessUnitsList.vue b/frontend/src/views/settings/BusinessUnitsList.vue new file mode 100644 index 00000000..f4efed79 --- /dev/null +++ b/frontend/src/views/settings/BusinessUnitsList.vue @@ -0,0 +1,191 @@ + + + diff --git a/frontend/src/views/settings/CustomFieldsList.vue b/frontend/src/views/settings/CustomFieldsList.vue new file mode 100644 index 00000000..8bb62238 --- /dev/null +++ b/frontend/src/views/settings/CustomFieldsList.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/frontend/src/views/settings/DashboardDefaultsList.vue b/frontend/src/views/settings/DashboardDefaultsList.vue new file mode 100644 index 00000000..17a9b64a --- /dev/null +++ b/frontend/src/views/settings/DashboardDefaultsList.vue @@ -0,0 +1,190 @@ + + + diff --git a/frontend/src/views/settings/DellWarrantySettings.vue b/frontend/src/views/settings/DellWarrantySettings.vue new file mode 100644 index 00000000..8c3a0daf --- /dev/null +++ b/frontend/src/views/settings/DellWarrantySettings.vue @@ -0,0 +1,117 @@ + + + diff --git a/frontend/src/views/settings/EmailSettings.vue b/frontend/src/views/settings/EmailSettings.vue new file mode 100644 index 00000000..7eb22fdb --- /dev/null +++ b/frontend/src/views/settings/EmailSettings.vue @@ -0,0 +1,216 @@ + + + diff --git a/frontend/src/views/settings/EmployeeDirectory.vue b/frontend/src/views/settings/EmployeeDirectory.vue new file mode 100644 index 00000000..0d9642bd --- /dev/null +++ b/frontend/src/views/settings/EmployeeDirectory.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/frontend/src/views/settings/FloorMapSettings.vue b/frontend/src/views/settings/FloorMapSettings.vue new file mode 100644 index 00000000..624918f6 --- /dev/null +++ b/frontend/src/views/settings/FloorMapSettings.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/src/views/settings/GlobalSearchSettings.vue b/frontend/src/views/settings/GlobalSearchSettings.vue new file mode 100644 index 00000000..659f198c --- /dev/null +++ b/frontend/src/views/settings/GlobalSearchSettings.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/views/settings/LocationTypesList.vue b/frontend/src/views/settings/LocationTypesList.vue new file mode 100644 index 00000000..c71115bf --- /dev/null +++ b/frontend/src/views/settings/LocationTypesList.vue @@ -0,0 +1,145 @@ + + + diff --git a/frontend/src/views/settings/LocationsList.vue b/frontend/src/views/settings/LocationsList.vue new file mode 100644 index 00000000..6cb4f433 --- /dev/null +++ b/frontend/src/views/settings/LocationsList.vue @@ -0,0 +1,388 @@ + + + + + diff --git a/frontend/src/views/settings/MachineTypesList.vue b/frontend/src/views/settings/MachineTypesList.vue new file mode 100644 index 00000000..3339aa92 --- /dev/null +++ b/frontend/src/views/settings/MachineTypesList.vue @@ -0,0 +1,145 @@ + + + diff --git a/frontend/src/views/settings/MeasuringToolTypesList.vue b/frontend/src/views/settings/MeasuringToolTypesList.vue new file mode 100644 index 00000000..901e058b --- /dev/null +++ b/frontend/src/views/settings/MeasuringToolTypesList.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/views/settings/ModelSuppliesList.vue b/frontend/src/views/settings/ModelSuppliesList.vue new file mode 100644 index 00000000..421959e6 --- /dev/null +++ b/frontend/src/views/settings/ModelSuppliesList.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/frontend/src/views/settings/ModelTypesList.vue b/frontend/src/views/settings/ModelTypesList.vue new file mode 100644 index 00000000..80b65d97 --- /dev/null +++ b/frontend/src/views/settings/ModelTypesList.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/src/views/settings/ModelsList.vue b/frontend/src/views/settings/ModelsList.vue new file mode 100644 index 00000000..73120510 --- /dev/null +++ b/frontend/src/views/settings/ModelsList.vue @@ -0,0 +1,456 @@ + + + + + diff --git a/frontend/src/views/settings/NetworkTypesList.vue b/frontend/src/views/settings/NetworkTypesList.vue new file mode 100644 index 00000000..ef9ce57a --- /dev/null +++ b/frontend/src/views/settings/NetworkTypesList.vue @@ -0,0 +1,145 @@ + + + diff --git a/frontend/src/views/settings/OperatingSystemsList.vue b/frontend/src/views/settings/OperatingSystemsList.vue new file mode 100644 index 00000000..420418f3 --- /dev/null +++ b/frontend/src/views/settings/OperatingSystemsList.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/frontend/src/views/settings/PCTypeMappingSettings.vue b/frontend/src/views/settings/PCTypeMappingSettings.vue new file mode 100644 index 00000000..457a3e7f --- /dev/null +++ b/frontend/src/views/settings/PCTypeMappingSettings.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/views/settings/PCTypesList.vue b/frontend/src/views/settings/PCTypesList.vue new file mode 100644 index 00000000..0118bfdb --- /dev/null +++ b/frontend/src/views/settings/PCTypesList.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/views/settings/PluginsList.vue b/frontend/src/views/settings/PluginsList.vue new file mode 100644 index 00000000..d8d4eb80 --- /dev/null +++ b/frontend/src/views/settings/PluginsList.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/frontend/src/views/settings/PrinterDriversList.vue b/frontend/src/views/settings/PrinterDriversList.vue new file mode 100644 index 00000000..efa2d14f --- /dev/null +++ b/frontend/src/views/settings/PrinterDriversList.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/frontend/src/views/settings/PrinterTypesList.vue b/frontend/src/views/settings/PrinterTypesList.vue new file mode 100644 index 00000000..7510cd06 --- /dev/null +++ b/frontend/src/views/settings/PrinterTypesList.vue @@ -0,0 +1,145 @@ + + + diff --git a/frontend/src/views/settings/PrintingSettings.vue b/frontend/src/views/settings/PrintingSettings.vue new file mode 100644 index 00000000..d0c493aa --- /dev/null +++ b/frontend/src/views/settings/PrintingSettings.vue @@ -0,0 +1,192 @@ + + + diff --git a/frontend/src/views/settings/RelationshipTypesList.vue b/frontend/src/views/settings/RelationshipTypesList.vue new file mode 100644 index 00000000..9ed6ef6f --- /dev/null +++ b/frontend/src/views/settings/RelationshipTypesList.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/src/views/settings/ServiceNowSettings.vue b/frontend/src/views/settings/ServiceNowSettings.vue new file mode 100644 index 00000000..bfdb8c3f --- /dev/null +++ b/frontend/src/views/settings/ServiceNowSettings.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/views/settings/SettingsIndex.vue b/frontend/src/views/settings/SettingsIndex.vue new file mode 100644 index 00000000..53ab336c --- /dev/null +++ b/frontend/src/views/settings/SettingsIndex.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/src/views/settings/SettingsLayout.vue b/frontend/src/views/settings/SettingsLayout.vue new file mode 100644 index 00000000..09ff578a --- /dev/null +++ b/frontend/src/views/settings/SettingsLayout.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/views/settings/SiteSettings.vue b/frontend/src/views/settings/SiteSettings.vue new file mode 100644 index 00000000..e52c0e0a --- /dev/null +++ b/frontend/src/views/settings/SiteSettings.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/views/settings/SlideManager.vue b/frontend/src/views/settings/SlideManager.vue new file mode 100644 index 00000000..1aad6a7f --- /dev/null +++ b/frontend/src/views/settings/SlideManager.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/views/settings/StatusesList.vue b/frontend/src/views/settings/StatusesList.vue new file mode 100644 index 00000000..1eb6143a --- /dev/null +++ b/frontend/src/views/settings/StatusesList.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/frontend/src/views/settings/SubnetsList.vue b/frontend/src/views/settings/SubnetsList.vue new file mode 100644 index 00000000..b32e0252 --- /dev/null +++ b/frontend/src/views/settings/SubnetsList.vue @@ -0,0 +1,567 @@ + + + + + diff --git a/frontend/src/views/settings/SupportTeamsList.vue b/frontend/src/views/settings/SupportTeamsList.vue new file mode 100644 index 00000000..ec14e1d1 --- /dev/null +++ b/frontend/src/views/settings/SupportTeamsList.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/frontend/src/views/settings/UsersList.vue b/frontend/src/views/settings/UsersList.vue new file mode 100644 index 00000000..f9dc557c --- /dev/null +++ b/frontend/src/views/settings/UsersList.vue @@ -0,0 +1,746 @@ + + + + + diff --git a/frontend/src/views/settings/VLANsList.vue b/frontend/src/views/settings/VLANsList.vue new file mode 100644 index 00000000..71b2eb85 --- /dev/null +++ b/frontend/src/views/settings/VLANsList.vue @@ -0,0 +1,377 @@ + + + + + diff --git a/frontend/src/views/settings/ZabbixSettings.vue b/frontend/src/views/settings/ZabbixSettings.vue new file mode 100644 index 00000000..4289e445 --- /dev/null +++ b/frontend/src/views/settings/ZabbixSettings.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/views/settings/settingsNav.js b/frontend/src/views/settings/settingsNav.js new file mode 100644 index 00000000..ea845605 --- /dev/null +++ b/frontend/src/views/settings/settingsNav.js @@ -0,0 +1,127 @@ +// Shared settings navigation catalog. +// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the +// grouping lives in one place. +// Order: site identity + the reference-data catalogs users touch daily come +// first, then the platform/system groups (integrations, communication, search, +// plugins, access) cluster together at the end. +import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key } from 'lucide-vue-next' + +export const settingsGroups = [ + { + title: 'Site & Facility', + cards: [ + { to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' }, + { to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' }, + { to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' }, + ], + }, + { + title: 'General Reference', + cards: [ + { to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' }, + { to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' }, + { to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' }, + { to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' }, + { to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' }, + { to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' }, + { to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' }, + { to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' }, + // Operating Systems is cross-asset (PCs, machines, measuring tools, network + // devices all run an OS), so it lives in General Reference, not under PCs. + { to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' }, + ], + }, + { + title: 'PCs', + cards: [ + { to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' }, + { to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' }, + ], + }, + { + title: 'Printers', + cards: [ + { to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' }, + { to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' }, + { to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' }, + ], + }, + { + title: 'Machines', + cards: [ + { to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' }, + ], + }, + // The "Measuring Tools" cards are contributed by the measuringtools plugin via + // the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog). + // This empty placeholder fixes the group's position among the asset groups + // (right after Machines); the merger fills it in place instead of appending a + // new group at the end. When the plugin is disabled the group stays empty and + // the rail/landing drop it (empty groups never render). + { + title: 'Measuring Tools', + cards: [], + }, + { + title: 'Locations & Organization', + cards: [ + { to: '/settings/employeedirectory', icon: Contact, title: 'Employee Directory', description: 'Manage the self-hosted people directory (add/edit/import); read-only in external HR mode' }, + { to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' }, + { to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' }, + { to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' }, + ], + }, + { + title: 'Network', + cards: [ + { to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' }, + { to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' }, + { to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' }, + ], + }, + { + title: 'Displays & Kiosks', + cards: [ + { to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' }, + { to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' }, + ], + }, + { + title: 'Integrations', + cards: [ + { to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' }, + { to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' }, + { to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' }, + ], + }, + { + title: 'Communication', + cards: [ + { to: '/settings/email', icon: Mail, title: 'Email / SMTP', description: 'SMTP mail host for email notifications, alerts, TLS, from address, and recipients' }, + ], + }, + { + title: 'Search & Identity', + cards: [ + { to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' }, + { to: '/settings/assetidentifiers', icon: Fingerprint, title: 'Asset Identifiers', description: 'Enable optional asset identifiers per type: gauge lab, maintenance, FQDN hostname' }, + { to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code and USB label targets, barcode style, and URL templates' }, + ], + }, + { + title: 'System', + cards: [ + { to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' }, + ], + }, + { + title: 'Access & Security', + cards: [ + { to: '/settings/authentication', icon: KeyRound, title: 'Authentication', description: 'SAML single sign-on (SSO) with your IdP, local login, and auto-create users' }, + { to: '/settings/apitokens', icon: Key, title: 'API Tokens', description: 'Personal access tokens for scripts and integrations (e.g. long-running imports) that outlive login sessions' }, + { to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' }, + { to: '/settings/audit', icon: History, title: 'Audit & Logging', description: 'Audit log retention period and history purge policy' }, + { to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' }, + ], + }, +] diff --git a/frontend/src/views/usb/USBDetail.vue b/frontend/src/views/usb/USBDetail.vue new file mode 100644 index 00000000..e32d6c33 --- /dev/null +++ b/frontend/src/views/usb/USBDetail.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/frontend/src/views/usb/USBForm.vue b/frontend/src/views/usb/USBForm.vue new file mode 100644 index 00000000..ab26e478 --- /dev/null +++ b/frontend/src/views/usb/USBForm.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/views/usb/USBList.vue b/frontend/src/views/usb/USBList.vue new file mode 100644 index 00000000..40027c69 --- /dev/null +++ b/frontend/src/views/usb/USBList.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/frontend/src/views/vendors/VendorsList.vue b/frontend/src/views/vendors/VendorsList.vue new file mode 100644 index 00000000..6238a6d8 --- /dev/null +++ b/frontend/src/views/vendors/VendorsList.vue @@ -0,0 +1,330 @@ + + + diff --git a/frontend/src/views/warranty/WarrantiesList.vue b/frontend/src/views/warranty/WarrantiesList.vue new file mode 100644 index 00000000..8e3252d5 --- /dev/null +++ b/frontend/src/views/warranty/WarrantiesList.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 00000000..3e72a853 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +export default defineConfig({ + // Mount path of the built app. Default '/' (own IIS site / dev). Set + // VITE_BASE_PATH='/ops/' (trailing slash) to build for a subpath mount under + // Default Web Site, e.g. `VITE_BASE_PATH=/ops/ npm run build`. + base: process.env.VITE_BASE_PATH || '/', + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:5001', + changeOrigin: true + }, + '/static': { + target: 'http://localhost:5001', + changeOrigin: true + } + } + } +}) diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js new file mode 100644 index 00000000..0f2577e4 --- /dev/null +++ b/frontend/vitest.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +// Standalone vitest config so the unit runner stays independent of vite.config.js. +// jsdom gives component specs a DOM; the '@' alias mirrors the app build. +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + include: ['src/**/*.spec.js'], + }, +}) diff --git a/migrations/DATA_MIGRATION_GUIDE.md b/migrations/DATA_MIGRATION_GUIDE.md new file mode 100644 index 00000000..6e9c79da --- /dev/null +++ b/migrations/DATA_MIGRATION_GUIDE.md @@ -0,0 +1,13 @@ +# Legacy data migration (superseded) + +The direct-database migration this guide used to describe is retired. Legacy +data now enters through the HTTP import surface, which runs authorization, +validation, auditing, and plugin hooks on every row: + +- Contract: the IMPORT-API wiki page (admin API token + `X-Import-Mode: true`, + which preserves original created/modified timestamps). +- Adoption playbook for a new site: the IMPORT-ADOPTION wiki page. +- Worked reference loader (West Jefferson classic-ASP schema, 15 stages, + idempotent and resumable): `scripts/site_imports/wjf/`. +- Full pilot sequence (stand up + import + verify + cutover): + the PILOT-DEPLOY wiki page. diff --git a/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md b/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md new file mode 100644 index 00000000..82612f3e --- /dev/null +++ b/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md @@ -0,0 +1,89 @@ +# Fix LocationOnly Equipment Types Migration + +## Issue +During the original migration from the VBScript ShopDB site, 110 machines were imported with `LocationOnly` type even though they have models assigned that specify the correct equipment type. + +## Root Cause +The migration copied the `machinetypeid` directly from the `machines` table, but many machines had `machinetypeid=1` (LocationOnly) even when they had a model with a proper type. + +## Affected Records +- **110 machines** in the `machines` table +- **110 equipment** records in the `equipment` table + +## Example +| Machine | Current Type | Should Be | Model | +|---------|-------------|-----------|-------| +| 7502 | LocationOnly | Horizontal Machining Center | a81nx | +| 3108 | LocationOnly | Vertical Lathe | VTM-100 | +| 4001 | LocationOnly | 5-axis Mill | VP9000 | + +## Fix SQL + +### Step 1: Preview affected records +```sql +-- Preview what will be changed +SELECT + m.machineid, + m.machinenumber, + mt_current.machinetype as current_type, + mt_model.machinetype as correct_type, + mo.modelnumber +FROM machines m +JOIN models mo ON mo.modelnumberid = m.modelnumberid +JOIN machinetypes mt_current ON mt_current.machinetypeid = m.machinetypeid +JOIN machinetypes mt_model ON mt_model.machinetypeid = mo.machinetypeid +WHERE m.machinetypeid = 1 -- Currently LocationOnly +AND mo.machinetypeid != 1; -- Model has a real type +``` + +### Step 2: Fix the machines table +```sql +-- Update machines table to use the model's machine type +UPDATE machines m +JOIN models mo ON mo.modelnumberid = m.modelnumberid +SET m.machinetypeid = mo.machinetypeid +WHERE m.machinetypeid = 1 -- Currently LocationOnly +AND mo.machinetypeid != 1; -- Model has a real type +``` + +### Step 3: Fix the equipment table +```sql +-- Update equipment table to match +-- Note: equipmenttypeid values match machinetypeid values +UPDATE equipment e +JOIN assets a ON a.assetid = e.assetid +JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1) +SET e.equipmenttypeid = m.machinetypeid +WHERE e.equipmenttypeid = 1 -- Currently LocationOnly +AND m.machinetypeid != 1; -- Machine now has correct type +``` + +### Alternative Step 3 (if asset numbers don't match) +```sql +-- Update equipment based on their own model reference +UPDATE equipment e +JOIN models mo ON mo.modelnumberid = e.modelnumberid +SET e.equipmenttypeid = mo.machinetypeid +WHERE e.equipmenttypeid = 1 -- Currently LocationOnly +AND mo.machinetypeid != 1 -- Model has a real type +AND e.modelnumberid IS NOT NULL; +``` + +## Verification +```sql +-- Count remaining LocationOnly records that should have been fixed +SELECT COUNT(*) as remaining_fixable +FROM machines m +JOIN models mo ON mo.modelnumberid = m.modelnumberid +WHERE m.machinetypeid = 1 +AND mo.machinetypeid != 1; +-- Should return 0 after fix +``` + +## Notes for Future Migrations +1. When importing equipment, always check if the model has a type and use that as the default +2. Only use "LocationOnly" for genuine location markers (rooms, offices) without models +3. Validate after import: any equipment with a model should NOT be LocationOnly + +## Date Created +2026-01-27 diff --git a/migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md b/migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md new file mode 100644 index 00000000..906af210 --- /dev/null +++ b/migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md @@ -0,0 +1,92 @@ +# Migrate USB Devices from Equipment Table + +## Issue +6 USB devices were incorrectly stored in the `equipment` table instead of the proper `usbdevices` table. + +## Status +- [x] `pin` field added to `usbdevices` table for encrypted devices +- [x] `pin` field added to USBDevice model +- [x] USB Device equipment type deactivated (equipmenttypeid=44) +- [ ] Migrate 6 USB devices to usbdevices table + +## Affected Records +| Asset Number | Name | Serial Number | +|--------------|------|---------------| +| 82841957-5891 | Green Kingston 64GB | 82841957 | +| 48854302-5892 | Blue Kingston 64GB | 48854302 | +| 75953637-5893 | Blue Kingston 64GB USB 3.0 | 75953637 | +| 41299370-5904 | Lenovo Portable DVD | 41299370 | +| 15492331-5905 | TEAC Portable Floppy Drive | 15492331 | +| 25777358-5906 | Netgear WiFi Adapter | 25777358 | + +## Migration SQL + +### Step 1: Insert into usbdevices +```sql +INSERT INTO usbdevices ( + serialnumber, + label, + assetnumber, + usbdevicetypeid, + storagelocation, + notes, + createddate, + modifieddate, + isactive +) +SELECT + a.serialnumber, + a.name, + a.assetnumber, + NULL, -- Assign proper type later + NULL, -- Storage location TBD + a.notes, + a.createddate, + a.modifieddate, + a.isactive +FROM assets a +JOIN equipment e ON e.assetid = a.assetid +WHERE e.equipmenttypeid = 44; -- USB Device type +``` + +### Step 2: Delete from equipment table +```sql +DELETE e FROM equipment e +JOIN assets a ON a.assetid = e.assetid +WHERE e.equipmenttypeid = 44; +``` + +### Step 3: Delete from assets table +```sql +DELETE a FROM assets a +JOIN equipment e ON e.assetid = a.assetid +WHERE e.equipmenttypeid = 44; + +-- Note: Run step 2 first since it references assets +``` + +### Alternative: Manual Migration +Since there are only 6 devices, you may prefer to: +1. Manually create them in the USB Devices section +2. Delete the equipment/asset records + +## USB Device Types to Create +```sql +-- Check if types exist, create if needed +INSERT IGNORE INTO usbdevicetypes (typename, description, icon) VALUES +('Flash Drive', 'USB flash drive / thumb drive', 'usb'), +('External HDD', 'External hard disk drive', 'harddisk'), +('External SSD', 'External solid state drive', 'harddisk'), +('Card Reader', 'SD/CF card reader', 'sdcard'), +('Optical Drive', 'External CD/DVD/Blu-ray drive', 'disc'), +('WiFi Adapter', 'USB wireless network adapter', 'wifi'), +('Other', 'Other USB device', 'usb'); +``` + +## Notes +- USB Device equipment type has been deactivated (isactive=0) +- New USB devices should be created in the USB Devices section +- USB devices do NOT need map positions (no mapleft/maptop) + +## Date Created +2026-01-27 diff --git a/migrations/PRODUCTION_MIGRATION_GUIDE.md b/migrations/PRODUCTION_MIGRATION_GUIDE.md new file mode 100644 index 00000000..3f3874a6 --- /dev/null +++ b/migrations/PRODUCTION_MIGRATION_GUIDE.md @@ -0,0 +1,165 @@ +# Production Migration Guide + +## Overview +This guide documents the process for migrating data from the legacy VBScript ShopDB site to the new Flask-based ShopDB. + +## Database Architecture + +### Legacy System (VBScript) +- Single `machines` table containing all equipment, PCs, printers +- `machinetypes` table for classification +- `models` table with `machinetypeid` reference + +### New System (Flask) +- Core `assets` table (unified asset registry) +- Plugin-specific extension tables: + - `equipment` (equipmenttypeid → equipmenttypes) + - `computers` (computertypeid → computertypes) + - `printers` (printertypeid → printertypes) + - `network_device` (networkdevicetypeid → networkdevicetypes) + +## Key Mappings + +### Asset Type Mapping +| Legacy Category | New Asset Type | Extension Table | +|-----------------|---------------|-----------------| +| Equipment machines | Equipment | equipment | +| PC/Computer | Computer | computers | +| Printer | Printer | printers | +| Network (IDF, Switch, AP) | Network Device | network_device | + +### Type ID Alignment +The `equipmenttypes` table IDs match `machinetypes` IDs for easy migration: +- equipmenttypeid = machinetypeid (where applicable) + +## Migration Steps + +### Step 1: Export from Legacy Database +```sql +-- Export machines with all related data +SELECT + m.*, + mt.machinetype, + mo.modelnumber, + mo.machinetypeid as model_typeid, + v.vendor, + bu.businessunit, + s.status +FROM machines m +LEFT JOIN machinetypes mt ON mt.machinetypeid = m.machinetypeid +LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid +LEFT JOIN vendors v ON v.vendorid = m.vendorid +LEFT JOIN businessunits bu ON bu.businessunitid = m.businessunitid +LEFT JOIN statuses s ON s.statusid = m.statusid; +``` + +### Step 2: Create Assets +For each machine, create an asset record: +```sql +INSERT INTO assets (assetnumber, name, assettypeid, statusid, locationid, businessunitid, mapleft, maptop) +SELECT + CONCAT(machinenumber, '-', machineid), -- Unique asset number + alias, + CASE + WHEN category = 'PC' THEN 2 -- Computer + WHEN category = 'Printer' THEN 4 -- Printer + ELSE 1 -- Equipment + END, + statusid, + locationid, + businessunitid, + mapleft, + maptop +FROM machines; +``` + +### Step 3: Create Extension Records +```sql +-- For Equipment +INSERT INTO equipment (assetid, equipmenttypeid, vendorid, modelnumberid) +SELECT + a.assetid, + COALESCE(mo.machinetypeid, m.machinetypeid), -- Use model's type if available! + m.vendorid, + m.modelnumberid +FROM machines m +JOIN assets a ON a.assetnumber = CONCAT(m.machinenumber, '-', m.machineid) +LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid +WHERE m.category = 'Equipment' OR m.category IS NULL; +``` + +### Step 4: Post-Migration Fixes + +#### Fix LocationOnly Equipment Types +Equipment imported with LocationOnly type should inherit type from their model: +```sql +-- Fix equipment that has a model with a proper type +UPDATE equipment e +JOIN assets a ON a.assetid = e.assetid +JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1) +JOIN models mo ON mo.modelnumberid = m.modelnumberid +SET e.equipmenttypeid = mo.machinetypeid +WHERE e.equipmenttypeid = 1 -- LocationOnly +AND mo.machinetypeid != 1; -- Model has real type +``` + +## Validation Queries + +### Check for Orphaned Assets +```sql +SELECT a.* FROM assets a +LEFT JOIN equipment e ON e.assetid = a.assetid +LEFT JOIN computers c ON c.assetid = a.assetid +LEFT JOIN printers p ON p.assetid = a.assetid +WHERE a.assettypeid = 1 -- Equipment type +AND e.assetid IS NULL; +``` + +### Check LocationOnly with Models +```sql +-- Should return 0 after migration fix +SELECT COUNT(*) +FROM equipment e +JOIN assets a ON a.assetid = e.assetid +JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1) +JOIN models mo ON mo.modelnumberid = m.modelnumberid +WHERE e.equipmenttypeid = 1 +AND mo.machinetypeid != 1; +``` + +### Verify Type Distribution +```sql +SELECT et.equipmenttype, COUNT(*) as count +FROM equipment e +JOIN equipmenttypes et ON et.equipmenttypeid = e.equipmenttypeid +GROUP BY et.equipmenttype +ORDER BY count DESC; +``` + +## Common Issues + +### Issue: Equipment marked as LocationOnly but has model +**Cause**: Migration copied machinetypeid from machines table instead of using model's type +**Fix**: See FIX_LOCATIONONLY_EQUIPMENT_TYPES.md + +### Issue: Missing model relationship +**Cause**: Equipment table modelnumberid not populated during migration +**Fix**: Link through machines table using asset number pattern + +### Issue: Duplicate asset numbers +**Cause**: Asset number generation didn't account for existing duplicates +**Fix**: Use unique suffix or check before insert + +## Scripts Location +- `/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - Fix for LocationOnly type issue +- `/scripts/import_from_mysql.py` - Original import script (may need updates) +- `/scripts/migration/` - Migration utilities + +## Notes +- Always backup before running migration fixes +- Test on staging/dev before production +- Verify counts before and after each fix +- Keep legacy `machines` table for reference during transition + +## Date Created +2026-01-27 diff --git a/migrations/README b/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 00000000..0c5f7bc4 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,169 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.schema import CreateTable + + +# Force every table the migrations create on MySQL to utf8mb4 + DYNAMIC row +# format. Without this a fresh `flask db upgrade` inherits the server default +# charset, so a box whose default is latin1 (common on older MySQL) silently +# builds a latin1 schema that drifts from the utf8mb4 production target. The +# DYNAMIC row format also keeps utf8mb4 indexes under the 767-byte prefix limit +# on pre-5.7 InnoDB. Scoped to the mysql dialect so the SQLite test DB is +# untouched. +@compiles(CreateTable, "mysql") +def _mysql_create_table_utf8mb4(element, compiler, **kw): + sql = compiler.visit_create_table(element, **kw) + if "CHARSET" not in sql.upper(): + sql = sql.rstrip().rstrip(";") + sql += ( + " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + " COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC" + ) + return sql + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + # Alembic creates alembic_version.version_num as VARCHAR(32), but some + # revision ids in this chain exceed 32 chars. On MySQL that truncates the + # stored id, so the next migration's version bump matches 0 rows and the + # upgrade dies. Force the column wide in its OWN committed connection first + # (keeping it out of alembic's migration transaction), so long ids never + # truncate - fresh or existing. + try: + with connectable.connect() as prep: + prep.exec_driver_sql( + 'CREATE TABLE IF NOT EXISTS alembic_version ' + '(version_num VARCHAR(128) NOT NULL, ' + 'CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num))' + ) + prep.exec_driver_sql( + 'ALTER TABLE alembic_version MODIFY version_num VARCHAR(128) NOT NULL' + ) + prep.commit() + except Exception: + # Non-MySQL backends (e.g. sqlite in tests) - alembic's default is fine. + pass + + with connectable.connect() as connection: + # Relax the session sql_mode for the migration run. Some historical + # migrations seed reference rows with raw INSERTs that omit NOT-NULL + # timestamp columns (the ORM supplies those via Python defaults at + # runtime, but a raw migration INSERT does not). MySQL 5.x's lax default + # accepted that; strict MySQL 8 rejects it with 1364 "Field 'createddate' + # doesn't have a default value". Dropping STRICT_TRANS_TABLES for the + # migration session only (the app's own connections keep their mode) + # makes the chain portable across MySQL versions. + try: + connection.exec_driver_sql( + "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'") + except Exception: + pass # non-MySQL backends (e.g. sqlite in tests) + + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/rename_underscore_columns.sql b/migrations/rename_underscore_columns.sql new file mode 100644 index 00000000..eeb3d362 --- /dev/null +++ b/migrations/rename_underscore_columns.sql @@ -0,0 +1,23 @@ +-- Rename database columns to remove underscores per naming convention +-- Date: 2026-02-03 +-- Affects: assettypes, assetrelationships, equipment, usbcheckouts + +-- 1. assettypes table +ALTER TABLE assettypes CHANGE COLUMN plugin_name pluginname VARCHAR(100) NULL COMMENT 'Plugin that owns this type'; +ALTER TABLE assettypes CHANGE COLUMN table_name tablename VARCHAR(100) NULL COMMENT 'Extension table name for this type'; + +-- 2. assetrelationships table +ALTER TABLE assetrelationships CHANGE COLUMN source_assetid sourceassetid INT NOT NULL; +ALTER TABLE assetrelationships CHANGE COLUMN target_assetid targetassetid INT NOT NULL; + +-- 3. equipment table +ALTER TABLE equipment CHANGE COLUMN controller_vendorid controllervendorid INT NULL COMMENT 'Controller vendor (e.g., FANUC)'; +ALTER TABLE equipment CHANGE COLUMN controller_modelid controllermodelid INT NULL COMMENT 'Controller model (e.g., 31B)'; + +-- 4. usbcheckouts table +ALTER TABLE usbcheckouts CHANGE COLUMN checkout_name checkoutname VARCHAR(100) NULL COMMENT 'Name of user'; +ALTER TABLE usbcheckouts CHANGE COLUMN checkout_time checkouttime DATETIME NOT NULL; +ALTER TABLE usbcheckouts CHANGE COLUMN checkin_time checkintime DATETIME NULL; +ALTER TABLE usbcheckouts CHANGE COLUMN checkout_reason checkoutreason TEXT NULL COMMENT 'Reason for checkout'; +ALTER TABLE usbcheckouts CHANGE COLUMN checkin_notes checkinnotes TEXT NULL; +ALTER TABLE usbcheckouts CHANGE COLUMN was_wiped waswiped TINYINT(1) NULL COMMENT 'Was device wiped after return'; diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/68b3947ae14f_baseline_schema.py b/migrations/versions/68b3947ae14f_baseline_schema.py new file mode 100644 index 00000000..faab40d5 --- /dev/null +++ b/migrations/versions/68b3947ae14f_baseline_schema.py @@ -0,0 +1,956 @@ +"""baseline schema + +Revision ID: 68b3947ae14f +Revises: +Create Date: 2026-05-08 17:53:08.342776 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '68b3947ae14f' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('appowners', + sa.Column('appownerid', sa.Integer(), nullable=False), + sa.Column('appowner', sa.String(length=100), nullable=False), + sa.Column('sso', sa.String(length=50), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('appownerid') + ) + op.create_table('assetstatuses', + sa.Column('statusid', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('statusid'), + sa.UniqueConstraint('status') + ) + op.create_table('assettypes', + sa.Column('assettypeid', sa.Integer(), nullable=False), + sa.Column('assettype', sa.String(length=50), nullable=False, comment='Category name: equipment, computer, network_device, printer'), + sa.Column('pluginname', sa.String(length=100), nullable=True, comment='Plugin that owns this type'), + sa.Column('tablename', sa.String(length=100), nullable=True, comment='Extension table name for this type'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('assettypeid'), + sa.UniqueConstraint('assettype') + ) + op.create_table('businessunits', + sa.Column('businessunitid', sa.Integer(), nullable=False), + sa.Column('businessunit', sa.String(length=100), nullable=False), + sa.Column('code', sa.String(length=20), nullable=True, comment='Short code'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('parentid', sa.Integer(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['parentid'], ['businessunits.businessunitid'], ), + sa.PrimaryKeyConstraint('businessunitid'), + sa.UniqueConstraint('businessunit'), + sa.UniqueConstraint('code') + ) + op.create_table('communicationtypes', + sa.Column('comtypeid', sa.Integer(), nullable=False), + sa.Column('comtype', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('comtypeid'), + sa.UniqueConstraint('comtype') + ) + op.create_table('computertypes', + sa.Column('computertypeid', sa.Integer(), nullable=False), + sa.Column('computertype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('computertypeid'), + sa.UniqueConstraint('computertype') + ) + op.create_table('equipmenttypes', + sa.Column('equipmenttypeid', sa.Integer(), nullable=False), + sa.Column('equipmenttype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('equipmenttypeid'), + sa.UniqueConstraint('equipmenttype') + ) + op.create_table('locations', + sa.Column('locationid', sa.Integer(), nullable=False), + sa.Column('locationname', sa.String(length=100), nullable=False), + sa.Column('building', sa.String(length=100), nullable=True), + sa.Column('floor', sa.String(length=50), nullable=True), + sa.Column('room', sa.String(length=50), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('mapimage', sa.String(length=500), nullable=True, comment='Path to floor map image'), + sa.Column('mapwidth', sa.Integer(), nullable=True), + sa.Column('mapheight', sa.Integer(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('locationid'), + sa.UniqueConstraint('locationname') + ) + op.create_table('machinestatuses', + sa.Column('statusid', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('statusid'), + sa.UniqueConstraint('status') + ) + op.create_table('machinetypes', + sa.Column('machinetypeid', sa.Integer(), nullable=False), + sa.Column('machinetype', sa.String(length=100), nullable=False), + sa.Column('category', sa.String(length=50), nullable=False, comment='Equipment, PC, Network, or Printer'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('machinetypeid'), + sa.UniqueConstraint('machinetype') + ) + op.create_table('networkdevicetypes', + sa.Column('networkdevicetypeid', sa.Integer(), nullable=False), + sa.Column('networkdevicetype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('networkdevicetypeid'), + sa.UniqueConstraint('networkdevicetype') + ) + op.create_table('notificationtypes', + sa.Column('notificationtypeid', sa.Integer(), nullable=False), + sa.Column('typename', sa.String(length=50), nullable=False), + sa.Column('typedescription', sa.Text(), nullable=True), + sa.Column('typecolor', sa.String(length=20), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('notificationtypeid') + ) + op.create_table('operatingsystems', + sa.Column('osid', sa.Integer(), nullable=False), + sa.Column('osname', sa.String(length=100), nullable=False), + sa.Column('osversion', sa.String(length=50), nullable=True), + sa.Column('architecture', sa.String(length=20), nullable=True, comment='x86, x64, ARM'), + sa.Column('endoflife', sa.Date(), nullable=True, comment='End of support date'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('osid'), + sa.UniqueConstraint('osname', 'osversion', name='uq_os_name_version') + ) + op.create_table('pctypes', + sa.Column('pctypeid', sa.Integer(), nullable=False), + sa.Column('pctype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('pctypeid'), + sa.UniqueConstraint('pctype') + ) + op.create_table('permissions', + sa.Column('permissionid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('category', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('permissionid'), + sa.UniqueConstraint('name') + ) + op.create_table('printertypes', + sa.Column('printertypeid', sa.Integer(), nullable=False), + sa.Column('printertype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('printertypeid'), + sa.UniqueConstraint('printertype') + ) + op.create_table('relationshiptypes', + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('relationshiptype', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('relationshiptypeid'), + sa.UniqueConstraint('relationshiptype') + ) + op.create_table('roles', + sa.Column('roleid', sa.Integer(), nullable=False), + sa.Column('rolename', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('roleid'), + sa.UniqueConstraint('rolename') + ) + op.create_table('settings', + sa.Column('settingid', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('key', sa.String(length=100), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.Column('valuetype', sa.String(length=20), nullable=True), + sa.Column('category', sa.String(length=50), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=True), + sa.Column('modifieddate', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('settingid') + ) + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_settings_key'), ['key'], unique=True) + + op.create_table('usbdevicetypes', + sa.Column('usbdevicetypeid', sa.Integer(), nullable=False), + sa.Column('typename', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('usbdevicetypeid'), + sa.UniqueConstraint('typename') + ) + op.create_table('users', + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('passwordhash', sa.String(length=255), nullable=False), + sa.Column('firstname', sa.String(length=100), nullable=True), + sa.Column('lastname', sa.String(length=100), nullable=True), + sa.Column('lastlogindate', sa.DateTime(), nullable=True), + sa.Column('failedlogins', sa.Integer(), nullable=True), + sa.Column('lockeduntil', sa.DateTime(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('userid'), + sa.UniqueConstraint('email') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True) + + op.create_table('vendors', + sa.Column('vendorid', sa.Integer(), nullable=False), + sa.Column('vendor', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('website', sa.String(length=255), nullable=True), + sa.Column('supportphone', sa.String(length=50), nullable=True), + sa.Column('supportemail', sa.String(length=100), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('vendorid'), + sa.UniqueConstraint('vendor') + ) + op.create_table('vlans', + sa.Column('vlanid', sa.Integer(), nullable=False), + sa.Column('vlannumber', sa.Integer(), nullable=False, comment='VLAN ID number'), + sa.Column('name', sa.String(length=100), nullable=False, comment='VLAN name'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('vlantype', sa.String(length=50), nullable=True, comment='Type: data, voice, management, guest, etc.'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('vlanid'), + sa.UniqueConstraint('vlannumber') + ) + with op.batch_alter_table('vlans', schema=None) as batch_op: + batch_op.create_index('idx_vlan_number', ['vlannumber'], unique=False) + + op.create_table('assets', + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('assetnumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'), + sa.Column('name', sa.String(length=100), nullable=True, comment='Display name/alias'), + sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'), + sa.Column('assettypeid', sa.Integer(), nullable=False), + sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'), + sa.Column('locationid', sa.Integer(), nullable=True), + sa.Column('businessunitid', sa.Integer(), nullable=True), + sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'), + sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('deleteddate', sa.DateTime(), nullable=True), + sa.Column('deletedby', sa.String(length=100), nullable=True), + sa.Column('createdby', sa.String(length=100), nullable=True), + sa.Column('modifiedby', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['assettypeid'], ['assettypes.assettypeid'], ), + sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ), + sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ), + sa.ForeignKeyConstraint(['statusid'], ['assetstatuses.statusid'], ), + sa.PrimaryKeyConstraint('assetid') + ) + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.create_index('idx_asset_active', ['isactive'], unique=False) + batch_op.create_index('idx_asset_location', ['locationid'], unique=False) + batch_op.create_index('idx_asset_status', ['statusid'], unique=False) + batch_op.create_index('idx_asset_type_bu', ['assettypeid', 'businessunitid'], unique=False) + batch_op.create_index(batch_op.f('ix_assets_assetnumber'), ['assetnumber'], unique=True) + batch_op.create_index(batch_op.f('ix_assets_serialnumber'), ['serialnumber'], unique=False) + + op.create_table('auditlogs', + sa.Column('auditlogid', sa.Integer(), nullable=False), + sa.Column('userid', sa.Integer(), nullable=True), + sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('ipaddress', sa.String(length=45), nullable=True), + sa.Column('useragent', sa.String(length=255), nullable=True), + sa.Column('action', sa.String(length=20), nullable=False), + sa.Column('entitytype', sa.String(length=50), nullable=False), + sa.Column('entityid', sa.Integer(), nullable=True), + sa.Column('entityname', sa.String(length=255), nullable=True), + sa.Column('changes', sa.Text(), nullable=True), + sa.Column('details', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['userid'], ['users.userid'], ), + sa.PrimaryKeyConstraint('auditlogid') + ) + with op.batch_alter_table('auditlogs', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_auditlogs_action'), ['action'], unique=False) + batch_op.create_index(batch_op.f('ix_auditlogs_entitytype'), ['entitytype'], unique=False) + batch_op.create_index(batch_op.f('ix_auditlogs_timestamp'), ['timestamp'], unique=False) + + op.create_table('models', + sa.Column('modelnumberid', sa.Integer(), nullable=False), + sa.Column('modelnumber', sa.String(length=100), nullable=False), + sa.Column('machinetypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('imageurl', sa.String(length=500), nullable=True, comment='URL to product image'), + sa.Column('documentationurl', sa.String(length=500), nullable=True, comment='URL to documentation'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('modelnumberid'), + sa.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor') + ) + op.create_table('notifications', + sa.Column('notificationid', sa.Integer(), nullable=False), + sa.Column('notificationtypeid', sa.Integer(), nullable=True), + sa.Column('businessunitid', sa.Integer(), nullable=True), + sa.Column('appid', sa.Integer(), nullable=True), + sa.Column('notification', sa.Text(), nullable=False, comment='The message content'), + sa.Column('starttime', sa.DateTime(), nullable=True), + sa.Column('endtime', sa.DateTime(), nullable=True), + sa.Column('ticketnumber', sa.String(length=50), nullable=True), + sa.Column('link', sa.String(length=500), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.Column('isshopfloor', sa.Boolean(), nullable=True), + sa.Column('employeesso', sa.String(length=100), nullable=True), + sa.Column('employeename', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['notificationtypeid'], ['notificationtypes.notificationtypeid'], ), + sa.PrimaryKeyConstraint('notificationid') + ) + op.create_table('rolepermissions', + sa.Column('roleid', sa.Integer(), nullable=False), + sa.Column('permissionid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['permissionid'], ['permissions.permissionid'], ), + sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ), + sa.PrimaryKeyConstraint('roleid', 'permissionid') + ) + op.create_table('subnets', + sa.Column('subnetid', sa.Integer(), nullable=False), + sa.Column('cidr', sa.String(length=18), nullable=False, comment='CIDR notation (e.g., 10.1.1.0/24)'), + sa.Column('name', sa.String(length=100), nullable=False, comment='Subnet name'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('gatewayip', sa.String(length=15), nullable=True, comment='Default gateway IP address'), + sa.Column('subnetmask', sa.String(length=15), nullable=True, comment='Subnet mask (e.g., 255.255.255.0)'), + sa.Column('networkaddress', sa.String(length=15), nullable=True, comment='Network address (e.g., 10.1.1.0)'), + sa.Column('broadcastaddress', sa.String(length=15), nullable=True, comment='Broadcast address (e.g., 10.1.1.255)'), + sa.Column('vlanid', sa.Integer(), nullable=True), + sa.Column('subnettype', sa.String(length=50), nullable=True, comment='Type: production, development, management, dmz, etc.'), + sa.Column('locationid', sa.Integer(), nullable=True), + sa.Column('dhcpenabled', sa.Boolean(), nullable=True, comment='DHCP enabled for this subnet'), + sa.Column('dhcprangestart', sa.String(length=15), nullable=True, comment='DHCP range start IP'), + sa.Column('dhcprangeend', sa.String(length=15), nullable=True, comment='DHCP range end IP'), + sa.Column('dns1', sa.String(length=15), nullable=True, comment='Primary DNS server'), + sa.Column('dns2', sa.String(length=15), nullable=True, comment='Secondary DNS server'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ), + sa.ForeignKeyConstraint(['vlanid'], ['vlans.vlanid'], ), + sa.PrimaryKeyConstraint('subnetid'), + sa.UniqueConstraint('cidr') + ) + with op.batch_alter_table('subnets', schema=None) as batch_op: + batch_op.create_index('idx_subnet_cidr', ['cidr'], unique=False) + batch_op.create_index('idx_subnet_location', ['locationid'], unique=False) + batch_op.create_index('idx_subnet_vlan', ['vlanid'], unique=False) + + op.create_table('supportteams', + sa.Column('supportteamid', sa.Integer(), nullable=False), + sa.Column('teamname', sa.String(length=100), nullable=False), + sa.Column('teamurl', sa.String(length=255), nullable=True), + sa.Column('appownerid', sa.Integer(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['appownerid'], ['appowners.appownerid'], ), + sa.PrimaryKeyConstraint('supportteamid') + ) + op.create_table('usbdevices', + sa.Column('usbdeviceid', sa.Integer(), nullable=False), + sa.Column('serialnumber', sa.String(length=100), nullable=False), + sa.Column('label', sa.String(length=100), nullable=True, comment='Human-readable label'), + sa.Column('assetnumber', sa.String(length=50), nullable=True, comment='Optional asset tag'), + sa.Column('usbdevicetypeid', sa.Integer(), nullable=True), + sa.Column('capacitygb', sa.Integer(), nullable=True, comment='Capacity in GB'), + sa.Column('vendorid', sa.String(length=10), nullable=True, comment='USB Vendor ID (hex)'), + sa.Column('productid', sa.String(length=10), nullable=True, comment='USB Product ID (hex)'), + sa.Column('manufacturer', sa.String(length=100), nullable=True), + sa.Column('productname', sa.String(length=100), nullable=True), + sa.Column('ischeckedout', sa.Boolean(), nullable=True), + sa.Column('currentuserid', sa.String(length=50), nullable=True, comment='SSO of current user'), + sa.Column('currentusername', sa.String(length=100), nullable=True, comment='Name of current user'), + sa.Column('currentcheckoutdate', sa.DateTime(), nullable=True), + sa.Column('storagelocation', sa.String(length=200), nullable=True, comment='Where device is stored when not checked out'), + sa.Column('pin', sa.String(length=50), nullable=True, comment='PIN for encrypted devices'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('createdby', sa.String(length=100), nullable=True), + sa.Column('modifiedby', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['usbdevicetypeid'], ['usbdevicetypes.usbdevicetypeid'], ), + sa.PrimaryKeyConstraint('usbdeviceid'), + sa.UniqueConstraint('serialnumber') + ) + with op.batch_alter_table('usbdevices', schema=None) as batch_op: + batch_op.create_index('idx_usb_checkedout', ['ischeckedout'], unique=False) + batch_op.create_index('idx_usb_currentuser', ['currentuserid'], unique=False) + batch_op.create_index('idx_usb_serial', ['serialnumber'], unique=False) + batch_op.create_index('idx_usb_type', ['usbdevicetypeid'], unique=False) + + op.create_table('userroles', + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('roleid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ), + sa.ForeignKeyConstraint(['userid'], ['users.userid'], ), + sa.PrimaryKeyConstraint('userid', 'roleid') + ) + op.create_table('applications', + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('appname', sa.String(length=100), nullable=False), + sa.Column('appdescription', sa.String(length=255), nullable=True), + sa.Column('supportteamid', sa.Integer(), nullable=True), + sa.Column('isinstallable', sa.Boolean(), nullable=True), + sa.Column('applicationnotes', sa.Text(), nullable=True), + sa.Column('installpath', sa.String(length=255), nullable=True), + sa.Column('applicationlink', sa.String(length=512), nullable=True), + sa.Column('documentationpath', sa.String(length=512), nullable=True), + sa.Column('ishidden', sa.Boolean(), nullable=True), + sa.Column('isprinter', sa.Boolean(), nullable=True), + sa.Column('islicenced', sa.Boolean(), nullable=True), + sa.Column('image', sa.String(length=255), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['supportteamid'], ['supportteams.supportteamid'], ), + sa.PrimaryKeyConstraint('appid'), + sa.UniqueConstraint('appname') + ) + op.create_table('assetrelationships', + sa.Column('relationshipid', sa.Integer(), nullable=False), + sa.Column('sourceassetid', sa.Integer(), nullable=False), + sa.Column('targetassetid', sa.Integer(), nullable=False), + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ), + sa.ForeignKeyConstraint(['sourceassetid'], ['assets.assetid'], ), + sa.ForeignKeyConstraint(['targetassetid'], ['assets.assetid'], ), + sa.PrimaryKeyConstraint('relationshipid'), + sa.UniqueConstraint('sourceassetid', 'targetassetid', 'relationshiptypeid', name='uq_asset_relationship') + ) + with op.batch_alter_table('assetrelationships', schema=None) as batch_op: + batch_op.create_index('idx_asset_rel_source', ['sourceassetid'], unique=False) + batch_op.create_index('idx_asset_rel_target', ['targetassetid'], unique=False) + + op.create_table('computers', + sa.Column('computerid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('computertypeid', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'), + sa.Column('osid', sa.Integer(), nullable=True), + sa.Column('loggedinuser', sa.String(length=100), nullable=True), + sa.Column('lastreporteddate', sa.DateTime(), nullable=True), + sa.Column('lastboottime', sa.DateTime(), nullable=True), + sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'), + sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'), + sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC (vs office PC)'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['computertypeid'], ['computertypes.computertypeid'], ), + sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ), + sa.PrimaryKeyConstraint('computerid') + ) + with op.batch_alter_table('computers', schema=None) as batch_op: + batch_op.create_index('idx_computer_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_computer_os', ['osid'], unique=False) + batch_op.create_index('idx_computer_type', ['computertypeid'], unique=False) + batch_op.create_index(batch_op.f('ix_computers_assetid'), ['assetid'], unique=True) + batch_op.create_index(batch_op.f('ix_computers_hostname'), ['hostname'], unique=False) + + op.create_table('equipment', + sa.Column('equipmentid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('equipmenttypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'), + sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual equipment)'), + sa.Column('lastmaintenancedate', sa.DateTime(), nullable=True), + sa.Column('nextmaintenancedate', sa.DateTime(), nullable=True), + sa.Column('maintenanceintervaldays', sa.Integer(), nullable=True), + sa.Column('controllervendorid', sa.Integer(), nullable=True, comment='Controller vendor (e.g., FANUC)'), + sa.Column('controllermodelid', sa.Integer(), nullable=True, comment='Controller model (e.g., 31B)'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['controllermodelid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['controllervendorid'], ['vendors.vendorid'], ), + sa.ForeignKeyConstraint(['equipmenttypeid'], ['equipmenttypes.equipmenttypeid'], ), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('equipmentid') + ) + with op.batch_alter_table('equipment', schema=None) as batch_op: + batch_op.create_index('idx_equipment_type', ['equipmenttypeid'], unique=False) + batch_op.create_index('idx_equipment_vendor', ['vendorid'], unique=False) + batch_op.create_index(batch_op.f('ix_equipment_assetid'), ['assetid'], unique=True) + + op.create_table('machines', + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('machinenumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'), + sa.Column('alias', sa.String(length=100), nullable=True, comment='Friendly name'), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname (for PCs)'), + sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'), + sa.Column('machinetypeid', sa.Integer(), nullable=False), + sa.Column('pctypeid', sa.Integer(), nullable=True, comment='Set for PCs, NULL for equipment'), + sa.Column('businessunitid', sa.Integer(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'), + sa.Column('locationid', sa.Integer(), nullable=True), + sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'), + sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'), + sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual machine)'), + sa.Column('osid', sa.Integer(), nullable=True), + sa.Column('loggedinuser', sa.String(length=100), nullable=True), + sa.Column('lastreporteddate', sa.DateTime(), nullable=True), + sa.Column('lastboottime', sa.DateTime(), nullable=True), + sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'), + sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'), + sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC'), + sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('deleteddate', sa.DateTime(), nullable=True), + sa.Column('deletedby', sa.String(length=100), nullable=True), + sa.Column('createdby', sa.String(length=100), nullable=True), + sa.Column('modifiedby', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ), + sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ), + sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ), + sa.ForeignKeyConstraint(['pctypeid'], ['pctypes.pctypeid'], ), + sa.ForeignKeyConstraint(['statusid'], ['machinestatuses.statusid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('machineid') + ) + with op.batch_alter_table('machines', schema=None) as batch_op: + batch_op.create_index('idx_machine_active', ['isactive'], unique=False) + batch_op.create_index('idx_machine_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_machine_location', ['locationid'], unique=False) + batch_op.create_index('idx_machine_type_bu', ['machinetypeid', 'businessunitid'], unique=False) + batch_op.create_index(batch_op.f('ix_machines_hostname'), ['hostname'], unique=False) + batch_op.create_index(batch_op.f('ix_machines_machinenumber'), ['machinenumber'], unique=True) + batch_op.create_index(batch_op.f('ix_machines_serialnumber'), ['serialnumber'], unique=False) + + op.create_table('networkdevices', + sa.Column('networkdeviceid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('networkdevicetypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'), + sa.Column('firmwareversion', sa.String(length=100), nullable=True), + sa.Column('portcount', sa.Integer(), nullable=True, comment='Number of ports (for switches)'), + sa.Column('ispoe', sa.Boolean(), nullable=True, comment='Power over Ethernet capable'), + sa.Column('ismanaged', sa.Boolean(), nullable=True, comment='Managed device (SNMP, web interface, etc.)'), + sa.Column('rackunit', sa.String(length=20), nullable=True, comment='Rack unit position (e.g., U1, U5)'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['networkdevicetypeid'], ['networkdevicetypes.networkdevicetypeid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('networkdeviceid') + ) + with op.batch_alter_table('networkdevices', schema=None) as batch_op: + batch_op.create_index('idx_netdev_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_netdev_type', ['networkdevicetypeid'], unique=False) + batch_op.create_index('idx_netdev_vendor', ['vendorid'], unique=False) + batch_op.create_index(batch_op.f('ix_networkdevices_assetid'), ['assetid'], unique=True) + batch_op.create_index(batch_op.f('ix_networkdevices_hostname'), ['hostname'], unique=False) + + op.create_table('printers', + sa.Column('printerid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('printertypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'), + sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'), + sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'), + sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'), + sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'), + sa.Column('pin', sa.String(length=20), nullable=True), + sa.Column('iscolor', sa.Boolean(), nullable=True, comment='Color capable'), + sa.Column('isduplex', sa.Boolean(), nullable=True, comment='Duplex capable'), + sa.Column('isnetwork', sa.Boolean(), nullable=True, comment='Network connected'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['printertypeid'], ['printertypes.printertypeid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('printerid') + ) + with op.batch_alter_table('printers', schema=None) as batch_op: + batch_op.create_index('idx_printer_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_printer_type', ['printertypeid'], unique=False) + batch_op.create_index('idx_printer_windowsname', ['windowsname'], unique=False) + batch_op.create_index(batch_op.f('ix_printers_assetid'), ['assetid'], unique=True) + batch_op.create_index(batch_op.f('ix_printers_hostname'), ['hostname'], unique=False) + + op.create_table('usbcheckouts', + sa.Column('checkoutid', sa.Integer(), nullable=False), + sa.Column('usbdeviceid', sa.Integer(), nullable=True), + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('sso', sa.String(length=20), nullable=False, comment='SSO of user'), + sa.Column('checkoutname', sa.String(length=100), nullable=True, comment='Name of user'), + sa.Column('checkouttime', sa.DateTime(), nullable=False), + sa.Column('checkintime', sa.DateTime(), nullable=True), + sa.Column('checkoutreason', sa.Text(), nullable=True, comment='Reason for checkout'), + sa.Column('checkinnotes', sa.Text(), nullable=True), + sa.Column('waswiped', sa.Boolean(), nullable=True, comment='Was device wiped after return'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['usbdeviceid'], ['usbdevices.usbdeviceid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('checkoutid') + ) + op.create_table('appversions', + sa.Column('appversionid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('version', sa.String(length=50), nullable=False), + sa.Column('releasedate', sa.Date(), nullable=True), + sa.Column('notes', sa.String(length=255), nullable=True), + sa.Column('dateadded', sa.DateTime(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.PrimaryKeyConstraint('appversionid'), + sa.UniqueConstraint('appid', 'version', name='uq_app_version') + ) + op.create_table('communications', + sa.Column('communicationid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=True, comment='FK to assets table (new architecture)'), + sa.Column('machineid', sa.Integer(), nullable=True, comment='DEPRECATED: FK to machines table - use assetid instead'), + sa.Column('comtypeid', sa.Integer(), nullable=False), + sa.Column('ipaddress', sa.String(length=50), nullable=True), + sa.Column('subnetmask', sa.String(length=50), nullable=True), + sa.Column('gateway', sa.String(length=50), nullable=True), + sa.Column('dns1', sa.String(length=50), nullable=True), + sa.Column('dns2', sa.String(length=50), nullable=True), + sa.Column('macaddress', sa.String(length=50), nullable=True), + sa.Column('isdhcp', sa.Boolean(), nullable=True), + sa.Column('comport', sa.String(length=20), nullable=True), + sa.Column('baudrate', sa.Integer(), nullable=True), + sa.Column('databits', sa.Integer(), nullable=True), + sa.Column('stopbits', sa.String(length=10), nullable=True), + sa.Column('parity', sa.String(length=20), nullable=True), + sa.Column('flowcontrol', sa.String(length=20), nullable=True), + sa.Column('port', sa.Integer(), nullable=True), + sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('pathname', sa.String(length=255), nullable=True), + sa.Column('pathname2', sa.String(length=255), nullable=True, comment='Secondary path for dualpath'), + sa.Column('isprimary', sa.Boolean(), nullable=True, comment='Primary communication method'), + sa.Column('ismachinenetwork', sa.Boolean(), nullable=True, comment='On machine network vs office network'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ), + sa.ForeignKeyConstraint(['comtypeid'], ['communicationtypes.comtypeid'], ), + sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ), + sa.PrimaryKeyConstraint('communicationid') + ) + with op.batch_alter_table('communications', schema=None) as batch_op: + batch_op.create_index('idx_comm_asset', ['assetid'], unique=False) + batch_op.create_index('idx_comm_ip', ['ipaddress'], unique=False) + batch_op.create_index('idx_comm_machine', ['machineid'], unique=False) + batch_op.create_index(batch_op.f('ix_communications_assetid'), ['assetid'], unique=False) + + op.create_table('knowledgebase', + sa.Column('linkid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=True), + sa.Column('shortdescription', sa.String(length=500), nullable=False), + sa.Column('linkurl', sa.String(length=2000), nullable=True), + sa.Column('keywords', sa.String(length=500), nullable=True), + sa.Column('clicks', sa.Integer(), nullable=True), + sa.Column('lastupdated', sa.DateTime(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.PrimaryKeyConstraint('linkid') + ) + op.create_table('machinerelationships', + sa.Column('relationshipid', sa.Integer(), nullable=False), + sa.Column('parentmachineid', sa.Integer(), nullable=False), + sa.Column('childmachineid', sa.Integer(), nullable=False), + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['childmachineid'], ['machines.machineid'], ), + sa.ForeignKeyConstraint(['parentmachineid'], ['machines.machineid'], ), + sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ), + sa.PrimaryKeyConstraint('relationshipid'), + sa.UniqueConstraint('parentmachineid', 'childmachineid', 'relationshiptypeid', name='uq_machine_relationship') + ) + op.create_table('printerdata', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'), + sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'), + sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'), + sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'), + sa.Column('pin', sa.String(length=20), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('printerdata', schema=None) as batch_op: + batch_op.create_index('idx_printerdata_windowsname', ['windowsname'], unique=False) + batch_op.create_index(batch_op.f('ix_printerdata_machineid'), ['machineid'], unique=True) + + op.create_table('computerinstalledapps', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('computerid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('appversionid', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('installeddate', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ), + sa.ForeignKeyConstraint(['computerid'], ['computers.computerid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('computerid', 'appid', name='uq_computer_app') + ) + with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op: + batch_op.create_index('idx_compapp_app', ['appid'], unique=False) + batch_op.create_index('idx_compapp_computer', ['computerid'], unique=False) + + op.create_table('installedapps', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('appversionid', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('installeddate', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ), + sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('machineid', 'appid', name='uq_machine_app') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('installedapps') + with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op: + batch_op.drop_index('idx_compapp_computer') + batch_op.drop_index('idx_compapp_app') + + op.drop_table('computerinstalledapps') + with op.batch_alter_table('printerdata', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_printerdata_machineid')) + batch_op.drop_index('idx_printerdata_windowsname') + + op.drop_table('printerdata') + op.drop_table('machinerelationships') + op.drop_table('knowledgebase') + with op.batch_alter_table('communications', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_communications_assetid')) + batch_op.drop_index('idx_comm_machine') + batch_op.drop_index('idx_comm_ip') + batch_op.drop_index('idx_comm_asset') + + op.drop_table('communications') + op.drop_table('appversions') + op.drop_table('usbcheckouts') + with op.batch_alter_table('printers', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_printers_hostname')) + batch_op.drop_index(batch_op.f('ix_printers_assetid')) + batch_op.drop_index('idx_printer_windowsname') + batch_op.drop_index('idx_printer_type') + batch_op.drop_index('idx_printer_hostname') + + op.drop_table('printers') + with op.batch_alter_table('networkdevices', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_networkdevices_hostname')) + batch_op.drop_index(batch_op.f('ix_networkdevices_assetid')) + batch_op.drop_index('idx_netdev_vendor') + batch_op.drop_index('idx_netdev_type') + batch_op.drop_index('idx_netdev_hostname') + + op.drop_table('networkdevices') + with op.batch_alter_table('machines', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_machines_serialnumber')) + batch_op.drop_index(batch_op.f('ix_machines_machinenumber')) + batch_op.drop_index(batch_op.f('ix_machines_hostname')) + batch_op.drop_index('idx_machine_type_bu') + batch_op.drop_index('idx_machine_location') + batch_op.drop_index('idx_machine_hostname') + batch_op.drop_index('idx_machine_active') + + op.drop_table('machines') + with op.batch_alter_table('equipment', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_equipment_assetid')) + batch_op.drop_index('idx_equipment_vendor') + batch_op.drop_index('idx_equipment_type') + + op.drop_table('equipment') + with op.batch_alter_table('computers', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_computers_hostname')) + batch_op.drop_index(batch_op.f('ix_computers_assetid')) + batch_op.drop_index('idx_computer_type') + batch_op.drop_index('idx_computer_os') + batch_op.drop_index('idx_computer_hostname') + + op.drop_table('computers') + with op.batch_alter_table('assetrelationships', schema=None) as batch_op: + batch_op.drop_index('idx_asset_rel_target') + batch_op.drop_index('idx_asset_rel_source') + + op.drop_table('assetrelationships') + op.drop_table('applications') + op.drop_table('userroles') + with op.batch_alter_table('usbdevices', schema=None) as batch_op: + batch_op.drop_index('idx_usb_type') + batch_op.drop_index('idx_usb_serial') + batch_op.drop_index('idx_usb_currentuser') + batch_op.drop_index('idx_usb_checkedout') + + op.drop_table('usbdevices') + op.drop_table('supportteams') + with op.batch_alter_table('subnets', schema=None) as batch_op: + batch_op.drop_index('idx_subnet_vlan') + batch_op.drop_index('idx_subnet_location') + batch_op.drop_index('idx_subnet_cidr') + + op.drop_table('subnets') + op.drop_table('rolepermissions') + op.drop_table('notifications') + op.drop_table('models') + with op.batch_alter_table('auditlogs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_auditlogs_timestamp')) + batch_op.drop_index(batch_op.f('ix_auditlogs_entitytype')) + batch_op.drop_index(batch_op.f('ix_auditlogs_action')) + + op.drop_table('auditlogs') + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_assets_serialnumber')) + batch_op.drop_index(batch_op.f('ix_assets_assetnumber')) + batch_op.drop_index('idx_asset_type_bu') + batch_op.drop_index('idx_asset_status') + batch_op.drop_index('idx_asset_location') + batch_op.drop_index('idx_asset_active') + + op.drop_table('assets') + with op.batch_alter_table('vlans', schema=None) as batch_op: + batch_op.drop_index('idx_vlan_number') + + op.drop_table('vlans') + op.drop_table('vendors') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_username')) + + op.drop_table('users') + op.drop_table('usbdevicetypes') + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_settings_key')) + + op.drop_table('settings') + op.drop_table('roles') + op.drop_table('relationshiptypes') + op.drop_table('printertypes') + op.drop_table('permissions') + op.drop_table('pctypes') + op.drop_table('operatingsystems') + op.drop_table('notificationtypes') + op.drop_table('networkdevicetypes') + op.drop_table('machinetypes') + op.drop_table('machinestatuses') + op.drop_table('locations') + op.drop_table('equipmenttypes') + op.drop_table('computertypes') + op.drop_table('communicationtypes') + op.drop_table('businessunits') + op.drop_table('assettypes') + op.drop_table('assetstatuses') + op.drop_table('appowners') + # ### end Alembic commands ### diff --git a/migrations/versions/7a01_adr001_position_contract.py b/migrations/versions/7a01_adr001_position_contract.py new file mode 100644 index 00000000..63b73fb1 --- /dev/null +++ b/migrations/versions/7a01_adr001_position_contract.py @@ -0,0 +1,147 @@ +"""ADR-001 position contract surface + +Rename Asset.mapleft -> Asset.mapx, Asset.maptop -> Asset.mapy. Add +Location.mapx / Location.mapy. Add AssetRelationship.label, +AssetRelationship.inheritsposition, RelationshipType.propagatesthroughid. +Seed the three canonical relationship types (partof, controls, connectedto) +with controls.propagatesthroughid -> partof. + +Revision ID: 7a01_adr001_position +Revises: 68b3947ae14f +Create Date: 2026-05-30 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7a01_adr001_position' +down_revision = '68b3947ae14f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ---- Asset.mapleft/maptop -> mapx/mapy -------------------------------- + with op.batch_alter_table('assets') as batch_op: + batch_op.alter_column('mapleft', new_column_name='mapx', + existing_type=sa.Integer(), existing_nullable=True, + comment='X coordinate on floor map (ADR-001)') + batch_op.alter_column('maptop', new_column_name='mapy', + existing_type=sa.Integer(), existing_nullable=True, + comment='Y coordinate on floor map (ADR-001)') + + # ---- Location: add mapx/mapy for the fallback path -------------------- + with op.batch_alter_table('locations') as batch_op: + batch_op.add_column(sa.Column('mapx', sa.Integer(), nullable=True, + comment='Default X coordinate for assets at this location')) + batch_op.add_column(sa.Column('mapy', sa.Integer(), nullable=True, + comment='Default Y coordinate for assets at this location')) + + # ---- RelationshipType: add propagatesthroughid self-FK ---------------- + with op.batch_alter_table('relationshiptypes') as batch_op: + batch_op.add_column(sa.Column('propagatesthroughid', sa.Integer(), + nullable=True, + comment='Sibling-propagation rail per ADR-001')) + batch_op.create_foreign_key( + 'fk_relationshiptype_propagation', + 'relationshiptypes', + ['propagatesthroughid'], + ['relationshiptypeid'], + ) + + # ---- AssetRelationship: add label + inheritsposition ------------------ + with op.batch_alter_table('assetrelationships') as batch_op: + batch_op.add_column(sa.Column('label', sa.String(length=200), nullable=True, + comment='Free-text relationship description (ADR-001)')) + batch_op.add_column(sa.Column('inheritsposition', sa.Boolean(), + nullable=False, server_default='1', + comment='If true, resolved-position walk follows this edge (ADR-001)')) + + # ---- Seed three canonical relationship types -------------------------- + # Idempotent: insert only if name is not already present. Then update + # controls.propagatesthroughid to point at partof. + relationshiptypes = sa.table( + 'relationshiptypes', + sa.column('relationshiptype', sa.String), + sa.column('description', sa.Text), + sa.column('propagatesthroughid', sa.Integer), + sa.column('isactive', sa.Boolean), + ) + + conn = op.get_bind() + dialect_name = conn.dialect.name + + # MySQL default collation is case-insensitive, which makes a plain + # `WHERE relationshiptype = 'controls'` match a legacy `Controls` row + # and silently skip the insert (then accidentally wire propagation onto + # the wrong row). Force binary comparison on MySQL so the three ADR + # types are always distinct from legacy capitalized names. SQLite + + # PostgreSQL are case-sensitive by default; the plain comparison is + # safe there. + def _eq(col_expr, value): + if dialect_name == 'mysql': + return f"BINARY {col_expr} = '{value}'" + return f"{col_expr} = '{value}'" + + for rt in ( + ('partof', 'Composition / sub-assembly (ADR-001)'), + ('controls', 'Operational authority over another asset (ADR-001)'), + ('connectedto', 'Network or data link without authority (ADR-001)'), + ): + existing = conn.execute(sa.text( + f"SELECT relationshiptypeid FROM relationshiptypes WHERE {_eq('relationshiptype', rt[0])}" + )).first() + if not existing: + conn.execute(relationshiptypes.insert().values( + relationshiptype=rt[0], + description=rt[1], + propagatesthroughid=None, + isactive=True, + )) + + partof_row = conn.execute(sa.text( + f"SELECT relationshiptypeid FROM relationshiptypes WHERE {_eq('relationshiptype', 'partof')}" + )).first() + if partof_row: + conn.execute(sa.text( + f"UPDATE relationshiptypes SET propagatesthroughid = :p WHERE {_eq('relationshiptype', 'controls')}" + ), {'p': partof_row[0]}) + + +def downgrade(): + # Revert in reverse order. Drops the seeded ADR types if no rows + # reference them; otherwise leaves them in place to avoid FK violations. + conn = op.get_bind() + + with op.batch_alter_table('assetrelationships') as batch_op: + batch_op.drop_column('inheritsposition') + batch_op.drop_column('label') + + with op.batch_alter_table('relationshiptypes') as batch_op: + batch_op.drop_constraint('fk_relationshiptype_propagation', type_='foreignkey') + batch_op.drop_column('propagatesthroughid') + + with op.batch_alter_table('locations') as batch_op: + batch_op.drop_column('mapy') + batch_op.drop_column('mapx') + + with op.batch_alter_table('assets') as batch_op: + batch_op.alter_column('mapx', new_column_name='mapleft', + existing_type=sa.Integer(), existing_nullable=True) + batch_op.alter_column('mapy', new_column_name='maptop', + existing_type=sa.Integer(), existing_nullable=True) + + # Best-effort: drop the seeded types if nothing references them. + for name in ('connectedto', 'controls', 'partof'): + try: + conn.execute(sa.text( + "DELETE FROM relationshiptypes " + "WHERE relationshiptype = :n " + "AND relationshiptypeid NOT IN (" + " SELECT relationshiptypeid FROM assetrelationships " + " UNION SELECT relationshiptypeid FROM machinerelationships" + ")" + ), {'n': name}) + except Exception: + pass diff --git a/migrations/versions/7b02_gaugelabreference.py b/migrations/versions/7b02_gaugelabreference.py new file mode 100644 index 00000000..165b98b4 --- /dev/null +++ b/migrations/versions/7b02_gaugelabreference.py @@ -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') diff --git a/migrations/versions/7c01_drop_legacy_machine.py b/migrations/versions/7c01_drop_legacy_machine.py new file mode 100644 index 00000000..b8276383 --- /dev/null +++ b/migrations/versions/7c01_drop_legacy_machine.py @@ -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") diff --git a/migrations/versions/7c02_application_isrequired.py b/migrations/versions/7c02_application_isrequired.py new file mode 100644 index 00000000..b0328995 --- /dev/null +++ b/migrations/versions/7c02_application_isrequired.py @@ -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') diff --git a/migrations/versions/7c03_locationtypes.py b/migrations/versions/7c03_locationtypes.py new file mode 100644 index 00000000..4b2de0f7 --- /dev/null +++ b/migrations/versions/7c03_locationtypes.py @@ -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') diff --git a/migrations/versions/7c04_fold_plugin_schema.py b/migrations/versions/7c04_fold_plugin_schema.py new file mode 100644 index 00000000..d95bd04f --- /dev/null +++ b/migrations/versions/7c04_fold_plugin_schema.py @@ -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') diff --git a/migrations/versions/7d01_dashboarddefaults.py b/migrations/versions/7d01_dashboarddefaults.py new file mode 100644 index 00000000..d7e9c6ea --- /dev/null +++ b/migrations/versions/7d01_dashboarddefaults.py @@ -0,0 +1,37 @@ +"""Add dashboarddefaults (visitor-IP -> business-unit mapping) + +A shopfloor / lobby kiosk resolves which business unit to display from the +display PC's IP. Powers the visitor-location lookup. + +Revision ID: 7d01_dashboarddefaults +Revises: 7c04_fold_plugin_schema +Create Date: 2026-06-27 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d01_dashboarddefaults' +down_revision = '7c04_fold_plugin_schema' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'dashboarddefaults', + sa.Column('dashboarddefaultid', sa.Integer(), primary_key=True), + sa.Column('ipaddress', sa.String(length=50), nullable=False, unique=True), + sa.Column('businessunitid', sa.Integer(), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=True), + sa.Column('modifieddate', sa.DateTime(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], + name='fk_dashboarddefaults_businessunit'), + ) + + +def downgrade(): + op.drop_table('dashboarddefaults') diff --git a/migrations/versions/7d02_widen_notification_employee_cols.py b/migrations/versions/7d02_widen_notification_employee_cols.py new file mode 100644 index 00000000..5ae506ab --- /dev/null +++ b/migrations/versions/7d02_widen_notification_employee_cols.py @@ -0,0 +1,37 @@ +"""Widen notifications.employeesso / employeename to TEXT + +Recognition and recertification notifications comma-join every listed +employee's SSO and name into a single column. VARCHAR(100) truncated the list +at ~11 people, dropping names off the shopfloor grid. TEXT removes the cap. + +Revision ID: 7d02_widen_notification_employee_cols +Revises: 7d01_dashboarddefaults +Create Date: 2026-07-07 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d02_widen_notification_employee_cols' +down_revision = '7d01_dashboarddefaults' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column('notifications', 'employeesso', + existing_type=sa.String(length=100), type_=sa.Text(), + existing_nullable=True) + op.alter_column('notifications', 'employeename', + existing_type=sa.String(length=100), type_=sa.Text(), + existing_nullable=True) + + +def downgrade(): + op.alter_column('notifications', 'employeesso', + existing_type=sa.Text(), type_=sa.String(length=100), + existing_nullable=True) + op.alter_column('notifications', 'employeename', + existing_type=sa.Text(), type_=sa.String(length=100), + existing_nullable=True) diff --git a/migrations/versions/7d03_notificationtype_expiry.py b/migrations/versions/7d03_notificationtype_expiry.py new file mode 100644 index 00000000..07f2125f --- /dev/null +++ b/migrations/versions/7d03_notificationtype_expiry.py @@ -0,0 +1,53 @@ +"""Per-type auto-expiry rule on notificationtypes + +Makes the shopfloor auto-expiry window configurable per notification type +instead of hardcoding recognition (8 AM Eastern) and recertification (14 days) +in the API. Adds: + expirymode 'none' | 'duration' | 'dailytime' + expirydays days for 'duration' + expiryhour hour (Eastern) for 'dailytime' + expiryminute minute (Eastern) for 'dailytime' + +Seeds the two existing rule-bearing types so behavior is unchanged: + Recognition (typecolor 'recognition') -> dailytime 08:00 Eastern + Recertification (typecolor 'recertification') -> duration 14 days + +Revision ID: 7d03_notificationtype_expiry +Revises: 7d02_widen_notification_employee_cols +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d03_notificationtype_expiry' +down_revision = '7d02_widen_notification_employee_cols' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('notificationtypes', sa.Column('expirymode', sa.String(length=20), nullable=True, server_default='none')) + op.add_column('notificationtypes', sa.Column('expirydays', sa.Integer(), nullable=True)) + op.add_column('notificationtypes', sa.Column('expiryhour', sa.SmallInteger(), nullable=True)) + op.add_column('notificationtypes', sa.Column('expiryminute', sa.SmallInteger(), nullable=True, server_default='0')) + + # preserve current behavior for the two rule-bearing types + op.execute( + "UPDATE notificationtypes SET expirymode='dailytime', expiryhour=8, expiryminute=0 " + "WHERE typecolor='recognition'" + ) + op.execute( + "UPDATE notificationtypes SET expirymode='duration', expirydays=14 " + "WHERE typecolor='recertification'" + ) + # everything else: explicit 'none' (indefinite unless the creator sets an end time) + op.execute("UPDATE notificationtypes SET expirymode='none' WHERE expirymode IS NULL") + + +def downgrade(): + op.drop_column('notificationtypes', 'expiryminute') + op.drop_column('notificationtypes', 'expiryhour') + op.drop_column('notificationtypes', 'expirydays') + op.drop_column('notificationtypes', 'expirymode') diff --git a/migrations/versions/7d04_notificationtype_display.py b/migrations/versions/7d04_notificationtype_display.py new file mode 100644 index 00000000..93da26b8 --- /dev/null +++ b/migrations/versions/7d04_notificationtype_display.py @@ -0,0 +1,43 @@ +"""Add data-driven shopfloor display behavior to notification types + +Replaces the hardcoded recognition/training/recertification logic with per-type +columns: split one card per employee, show employee photo, and the display +style (standard rows / carousel / grid / banner). Seeds the built-in special +types by their typecolor keyword. + +Revision ID: 7d04_notificationtype_display +Revises: 7d03_notificationtype_expiry +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d04_notificationtype_display' +down_revision = '7d03_notificationtype_expiry' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('notificationtypes', sa.Column('splitperemployee', sa.Boolean(), nullable=True, server_default='0')) + op.add_column('notificationtypes', sa.Column('showemployeephoto', sa.Boolean(), nullable=True, server_default='0')) + op.add_column('notificationtypes', sa.Column('displaystyle', sa.String(length=20), nullable=True, server_default='standard')) + + conn = op.get_bind() + conn.execute(sa.text( + "UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='carousel' " + "WHERE typecolor='recognition'")) + conn.execute(sa.text( + "UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='grid' " + "WHERE typecolor='recertification'")) + conn.execute(sa.text( + "UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='carousel' " + "WHERE typecolor='training'")) + + +def downgrade(): + op.drop_column('notificationtypes', 'displaystyle') + op.drop_column('notificationtypes', 'showemployeephoto') + op.drop_column('notificationtypes', 'splitperemployee') diff --git a/migrations/versions/7d05_pc_access_protocols.py b/migrations/versions/7d05_pc_access_protocols.py new file mode 100644 index 00000000..375394f5 --- /dev/null +++ b/migrations/versions/7d05_pc_access_protocols.py @@ -0,0 +1,76 @@ +"""PC access protocols: catalog + per-PC links, retire isvnc/iswinrm + +Adds an admin-managed protocol catalog (accessprotocols) and a per-PC link +table (computeraccess). Seeds VNC/WinRM/RDP, migrates each PC's isvnc/iswinrm +into computeraccess rows, then drops the two boolean columns. + +Revision ID: 7d05_pc_access_protocols +Revises: 7d04_notificationtype_display +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d05_pc_access_protocols' +down_revision = '7d04_notificationtype_display' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'accessprotocols', + sa.Column('protocolid', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('scheme', sa.String(length=20), nullable=False), + sa.Column('defaultport', sa.Integer(), nullable=True), + sa.Column('linktemplate', sa.String(length=255), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.UniqueConstraint('name', name='uq_accessprotocol_name'), + ) + op.create_table( + 'computeraccess', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('computerid', sa.Integer(), nullable=False), + sa.Column('protocolid', sa.Integer(), nullable=False), + sa.Column('portoverride', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.ForeignKeyConstraint(['computerid'], ['computers.computerid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['protocolid'], ['accessprotocols.protocolid']), + sa.UniqueConstraint('computerid', 'protocolid', name='uq_computer_protocol'), + ) + op.create_index('idx_compaccess_computer', 'computeraccess', ['computerid']) + + conn = op.get_bind() + # Seed the protocol catalog. exec_driver_sql so the ':' / '{}' in the + # templates are not parsed as bind params. + conn.exec_driver_sql( + "INSERT INTO accessprotocols (name, scheme, defaultport, linktemplate, isactive) VALUES " + "('VNC','vnc',5900,'vnc://{host}:{port}',1)," + "('WinRM','https',5986,'https://{host}:{port}/wsman',1)," + "('RDP','rdp',3389,'rdp://{host}:{port}',1)" + ) + # Migrate the old booleans into per-PC access rows. + conn.exec_driver_sql( + "INSERT INTO computeraccess (computerid, protocolid, isactive) " + "SELECT c.computerid, p.protocolid, 1 FROM computers c " + "JOIN accessprotocols p ON p.name='VNC' WHERE c.isvnc = 1" + ) + conn.exec_driver_sql( + "INSERT INTO computeraccess (computerid, protocolid, isactive) " + "SELECT c.computerid, p.protocolid, 1 FROM computers c " + "JOIN accessprotocols p ON p.name='WinRM' WHERE c.iswinrm = 1" + ) + + op.drop_column('computers', 'isvnc') + op.drop_column('computers', 'iswinrm') + + +def downgrade(): + op.add_column('computers', sa.Column('iswinrm', sa.Boolean(), nullable=True)) + op.add_column('computers', sa.Column('isvnc', sa.Boolean(), nullable=True)) + op.drop_index('idx_compaccess_computer', table_name='computeraccess') + op.drop_table('computeraccess') + op.drop_table('accessprotocols') diff --git a/migrations/versions/7d06_typecolor_to_hex.py b/migrations/versions/7d06_typecolor_to_hex.py new file mode 100644 index 00000000..e06eb116 --- /dev/null +++ b/migrations/versions/7d06_typecolor_to_hex.py @@ -0,0 +1,46 @@ +"""Give the built-in notification types real hex colors + +Recognition/Recertification/Training stored keyword typecolors ('recognition' +etc.) that static color maps had to translate. Every other type already stores +a hex. This converts the three to hex so color is fully data-driven and the +static maps can go away. Their special behavior now rides on displaystyle / +splitperemployee / showemployeephoto, not the color keyword. + +Revision ID: 7d06_typecolor_to_hex +Revises: 7d05_pc_access_protocols +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d06_typecolor_to_hex' +down_revision = '7d05_pc_access_protocols' +branch_labels = None +depends_on = None + + +KEYWORD_HEX = { + 'recognition': '#ffc107', + 'recertification': '#0d6efd', + 'training': '#17a2b8', +} + + +def upgrade(): + conn = op.get_bind() + for keyword, hexcolor in KEYWORD_HEX.items(): + conn.execute( + sa.text("UPDATE notificationtypes SET typecolor = :hex WHERE typecolor = :kw"), + {'hex': hexcolor, 'kw': keyword} + ) + + +def downgrade(): + conn = op.get_bind() + for keyword, hexcolor in KEYWORD_HEX.items(): + conn.execute( + sa.text("UPDATE notificationtypes SET typecolor = :kw WHERE typecolor = :hex"), + {'kw': keyword, 'hex': hexcolor} + ) diff --git a/migrations/versions/7d07_assettype_color.py b/migrations/versions/7d07_assettype_color.py new file mode 100644 index 00000000..16c7c1c1 --- /dev/null +++ b/migrations/versions/7d07_assettype_color.py @@ -0,0 +1,42 @@ +"""Add color to asset types (data-driven map/type colors) + +Gives AssetType a color column (like AssetStatus) so the map's top-level type +colors come from data instead of the hardcoded map in mapColors.js. Seeds the +four base types with the exact colors that were hardcoded, so nothing changes +visually until someone edits them. + +Revision ID: 7d07_assettype_color +Revises: 7d06_typecolor_to_hex +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d07_assettype_color' +down_revision = '7d06_typecolor_to_hex' +branch_labels = None +depends_on = None + + +SEED = { + 'equipment': '#F44336', + 'computer': '#2196F3', + 'printer': '#4CAF50', + 'network_device': '#FF9800', +} + + +def upgrade(): + op.add_column('assettypes', sa.Column('color', sa.String(length=20), nullable=True)) + conn = op.get_bind() + for assettype, hexcolor in SEED.items(): + conn.execute( + sa.text("UPDATE assettypes SET color = :c WHERE assettype = :t AND (color IS NULL OR color = '')"), + {'c': hexcolor, 't': assettype} + ) + + +def downgrade(): + op.drop_column('assettypes', 'color') diff --git a/migrations/versions/7d08_tvslides.py b/migrations/versions/7d08_tvslides.py new file mode 100644 index 00000000..6487aac4 --- /dev/null +++ b/migrations/versions/7d08_tvslides.py @@ -0,0 +1,36 @@ +"""Slide manager: tvslides table + +Backing table for the slides plugin (lobby display + shopfloor screensaver +playlists). Image files live on disk; this holds order + per-slide duration. + +Revision ID: 7d08_tvslides +Revises: 7d07_assettype_color +Create Date: 2026-07-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d08_tvslides' +down_revision = '7d07_assettype_color' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'tvslides', + sa.Column('slideid', sa.Integer(), primary_key=True), + sa.Column('surface', sa.String(length=20), nullable=False), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False, server_default='0'), + sa.Column('seconds', sa.Integer(), nullable=False, server_default='0'), + sa.Column('uploadeddate', sa.DateTime(), nullable=True), + ) + op.create_index('idx_tvslide_surface', 'tvslides', ['surface']) + + +def downgrade(): + op.drop_index('idx_tvslide_surface', table_name='tvslides') + op.drop_table('tvslides') diff --git a/migrations/versions/7d09_subtype_colors.py b/migrations/versions/7d09_subtype_colors.py new file mode 100644 index 00000000..dd15f6a4 --- /dev/null +++ b/migrations/versions/7d09_subtype_colors.py @@ -0,0 +1,31 @@ +"""Add color to asset subtype tables (data-driven map colors) + +The map colors equipment/computer/network/printer subtypes round-robin from a +palette. Give each subtype table a color column so a site can set stable, chosen +colors that the map reads instead of the auto-assigned palette. + +Revision ID: 7d09_subtype_colors +Revises: 7d08_tvslides +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d09_subtype_colors' +down_revision = '7d08_tvslides' +branch_labels = None +depends_on = None + +TABLES = ['equipmenttypes', 'computertypes', 'networkdevicetypes', 'printertypes'] + + +def upgrade(): + for table in TABLES: + op.add_column(table, sa.Column('color', sa.String(length=20), nullable=True)) + + +def downgrade(): + for table in TABLES: + op.drop_column(table, 'color') diff --git a/migrations/versions/7d10_relationshiptype_color.py b/migrations/versions/7d10_relationshiptype_color.py new file mode 100644 index 00000000..37508b65 --- /dev/null +++ b/migrations/versions/7d10_relationshiptype_color.py @@ -0,0 +1,26 @@ +"""Add color to relationship types + +Lets sites color relationship types (Controls, Contains, Stored At...) so the +asset relationship graph/badges are visually distinct, like statuses. + +Revision ID: 7d10_relationshiptype_color +Revises: 7d09_subtype_colors +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d10_relationshiptype_color' +down_revision = '7d09_subtype_colors' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('relationshiptypes', sa.Column('color', sa.String(length=20), nullable=True)) + + +def downgrade(): + op.drop_column('relationshiptypes', 'color') diff --git a/migrations/versions/7d11_locationtype_color.py b/migrations/versions/7d11_locationtype_color.py new file mode 100644 index 00000000..fc318e21 --- /dev/null +++ b/migrations/versions/7d11_locationtype_color.py @@ -0,0 +1,23 @@ +"""Add color to location types + +Revision ID: 7d11_locationtype_color +Revises: 7d10_relationshiptype_color +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d11_locationtype_color' +down_revision = '7d10_relationshiptype_color' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('locationtypes', sa.Column('color', sa.String(length=20), nullable=True)) + + +def downgrade(): + op.drop_column('locationtypes', 'color') diff --git a/migrations/versions/7d12_drop_computer_isshopfloor.py b/migrations/versions/7d12_drop_computer_isshopfloor.py new file mode 100644 index 00000000..5cc07c06 --- /dev/null +++ b/migrations/versions/7d12_drop_computer_isshopfloor.py @@ -0,0 +1,26 @@ +"""Drop computers.isshopfloor (redundant with the Shopfloor PC type) + +The flag was never populated (0 rows) and duplicated the 'Shopfloor' computer +type. Shopfloor classification now lives entirely in computertype. + +Revision ID: 7d12_drop_isshopfloor +Revises: 7d11_locationtype_color +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d12_drop_isshopfloor' +down_revision = '7d11_locationtype_color' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column('computers', 'isshopfloor') + + +def downgrade(): + op.add_column('computers', sa.Column('isshopfloor', sa.Boolean(), nullable=True, server_default='0')) diff --git a/migrations/versions/7d13_printerdrivers.py b/migrations/versions/7d13_printerdrivers.py new file mode 100644 index 00000000..39078f4d --- /dev/null +++ b/migrations/versions/7d13_printerdrivers.py @@ -0,0 +1,32 @@ +"""Printer drivers: named SMB/HTTP links to driver packages + +Revision ID: 7d13_printerdrivers +Revises: 7d12_drop_isshopfloor +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d13_printerdrivers' +down_revision = '7d12_drop_isshopfloor' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'printerdrivers', + sa.Column('driverid', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(length=150), nullable=False), + sa.Column('location', sa.String(length=500), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid']), + ) + + +def downgrade(): + op.drop_table('printerdrivers') diff --git a/migrations/versions/7d14_customfields.py b/migrations/versions/7d14_customfields.py new file mode 100644 index 00000000..3cfe84aa --- /dev/null +++ b/migrations/versions/7d14_customfields.py @@ -0,0 +1,48 @@ +"""Custom fields: site-defined extra attributes per asset type + +Revision ID: 7d14_customfields +Revises: 7d13_printerdrivers +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d14_customfields' +down_revision = '7d13_printerdrivers' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'customfields', + sa.Column('fieldid', sa.Integer(), primary_key=True), + sa.Column('assettypeid', sa.Integer(), nullable=False), + sa.Column('fieldkey', sa.String(length=50), nullable=False), + sa.Column('label', sa.String(length=150), nullable=False), + sa.Column('datatype', sa.String(length=20), nullable=False, server_default='text'), + sa.Column('options', sa.Text(), nullable=True), + sa.Column('showondetail', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('showonform', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('sortorder', sa.Integer(), nullable=False, server_default='0'), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.ForeignKeyConstraint(['assettypeid'], ['assettypes.assettypeid']), + sa.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'), + ) + op.create_table( + 'customfieldvalues', + sa.Column('valueid', sa.Integer(), primary_key=True), + sa.Column('fieldid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['fieldid'], ['customfields.fieldid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.UniqueConstraint('fieldid', 'assetid', name='uq_customfieldvalue_field_asset'), + ) + + +def downgrade(): + op.drop_table('customfieldvalues') + op.drop_table('customfields') diff --git a/migrations/versions/7d15_warranties.py b/migrations/versions/7d15_warranties.py new file mode 100644 index 00000000..7cf47d11 --- /dev/null +++ b/migrations/versions/7d15_warranties.py @@ -0,0 +1,45 @@ +"""Warranty plugin: warranties + warrantyassets + +Revision ID: 7d15_warranties +Revises: 7d14_customfields +Create Date: 2026-07-09 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d15_warranties' +down_revision = '7d14_customfields' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'warranties', + sa.Column('warrantyid', sa.Integer(), primary_key=True), + sa.Column('vendor', sa.String(length=100), nullable=False), + sa.Column('servicetag', sa.String(length=100), nullable=True), + sa.Column('provider', sa.String(length=20), nullable=False, server_default='manual'), + sa.Column('servicelevel', sa.String(length=150), nullable=True), + sa.Column('startdate', sa.Date(), nullable=True), + sa.Column('enddate', sa.Date(), nullable=True), + sa.Column('lastcheckeddate', sa.DateTime(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + ) + op.create_table( + 'warrantyassets', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('warrantyid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['warrantyid'], ['warranties.warrantyid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.UniqueConstraint('warrantyid', 'assetid', name='uq_warrantyasset_warranty_asset'), + ) + + +def downgrade(): + op.drop_table('warrantyassets') + op.drop_table('warranties') diff --git a/migrations/versions/7d16_directoryemployees.py b/migrations/versions/7d16_directoryemployees.py new file mode 100644 index 00000000..aeb2d14c --- /dev/null +++ b/migrations/versions/7d16_directoryemployees.py @@ -0,0 +1,31 @@ +"""Self-hosted employee directory table + +Revision ID: 7d16_directoryemployees +Revises: 7d15_warranties +Create Date: 2026-07-10 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d16_directoryemployees' +down_revision = '7d15_warranties' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'directoryemployees', + sa.Column('sso', sa.Integer(), primary_key=True, autoincrement=False), + sa.Column('firstname', sa.String(length=100), nullable=False), + sa.Column('lastname', sa.String(length=100), nullable=False), + sa.Column('team', sa.String(length=100), nullable=True), + sa.Column('role', sa.String(length=100), nullable=True), + sa.Column('picture', sa.String(length=255), nullable=True), + ) + + +def downgrade(): + op.drop_table('directoryemployees') diff --git a/migrations/versions/7d17_machines_rename.py b/migrations/versions/7d17_machines_rename.py new file mode 100644 index 00000000..2cb4b731 --- /dev/null +++ b/migrations/versions/7d17_machines_rename.py @@ -0,0 +1,205 @@ +"""Equipment -> machines rename, core half + +Part 1 of the approved equipment -> machines rename (the plugin half lives in +plugins/machines/migrations/versions/0002_rename_from_equipment.py): + + 1. machinetypes -> modeltypes (+ machinetypeid -> modeltypeid, + machinetype -> modeltype, models.machinetypeid -> models.modeltypeid). + The table types the vendor MODELS catalog, not machine instances, so + the new name is role-accurate and frees "machinetypes" for the plugin. + 2. Data flips: assettypes 'equipment' -> 'machine' (incl pluginname and + tablename), auditlogs.entitytype 'Equipment' -> 'Machine', settings keys + identifier_*_equipment_enabled -> identifier_*_machine_enabled, + search_equipment_enabled -> search_machine_enabled, and permission + rows equipment.* -> machines.* (role links survive). + 3. alembic_version_equipment -> alembic_version_machines (if present) so + the renamed plugin's migration chain resumes seamlessly. + +Idempotent via inspector guards, following the 7c01 pattern. MySQL is the +only dialect the core chain runs against in practice. + +Revision ID: 7d17_machines_rename +Revises: 7d16_directoryemployees +Create Date: 2026-07-11 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d17_machines_rename' +down_revision = '7d16_directoryemployees' +branch_labels = None +depends_on = None + + +def _fk_names(insp, table, referred_table): + # find FK constraint names on table pointing at referred_table + return [fk['name'] for fk in insp.get_foreign_keys(table) + if fk.get('referred_table') == referred_table and fk.get('name')] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + + # -- 1. machinetypes -> modeltypes ------------------------------------ + if 'machinetypes' in tables and 'modeltypes' not in tables: + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + + # drop the models -> machinetypes FK and its index before renaming + for name in _fk_names(insp, 'models', 'machinetypes'): + bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}") + model_indexes = {ix['name'] for ix in insp.get_indexes('models')} + if 'machinetypeid' in model_indexes: + bind.exec_driver_sql("ALTER TABLE models DROP KEY machinetypeid") + + bind.exec_driver_sql("RENAME TABLE machinetypes TO modeltypes") + bind.exec_driver_sql( + "ALTER TABLE modeltypes " + "CHANGE machinetypeid modeltypeid INT NOT NULL AUTO_INCREMENT, " + "CHANGE machinetype modeltype VARCHAR(100) NOT NULL, " + "DROP KEY machinetype, " + "ADD UNIQUE KEY modeltype (modeltype)" + ) + bind.exec_driver_sql( + "ALTER TABLE models " + "CHANGE machinetypeid modeltypeid INT NULL, " + "ADD KEY modeltypeid (modeltypeid), " + "ADD CONSTRAINT fk_models_modeltypeid " + "FOREIGN KEY (modeltypeid) REFERENCES modeltypes (modeltypeid)" + ) + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") + + # -- 2. data flips ------------------------------------------------------ + if 'assettypes' in tables: + bind.exec_driver_sql( + "UPDATE assettypes SET assettype='machine', pluginname='machines', " + "tablename='machines' WHERE assettype='equipment'" + ) + if 'auditlogs' in tables: + bind.exec_driver_sql( + "UPDATE auditlogs SET entitytype='Machine' " + "WHERE entitytype='Equipment'" + ) + if 'settings' in tables: + _rename_setting_keys(bind, old_marker='_equipment_', + new_marker='_machine_') + if 'permissions' in tables: + _rename_permissions(bind, old_prefix='equipment.', + new_prefix='machines.', + old_category='equipment', new_category='machines') + + # -- 3. plugin version table -------------------------------------------- + if ('alembic_version_equipment' in tables + and 'alembic_version_machines' not in tables): + bind.exec_driver_sql( + "RENAME TABLE alembic_version_equipment TO alembic_version_machines" + ) + + +def _rename_setting_keys(bind, old_marker, new_marker): + """Rename settings keys embedding the assettype value, collision-safe. + + Covers identifier__equipment_enabled and search_equipment_enabled. + If a row with the new key already exists (fresh seed ran on new code), + the old row is dropped instead of renamed. + """ + # broad fetch; the marker replace below filters non-matches + rows = bind.exec_driver_sql( + "SELECT settingid, `key` FROM settings" + ).fetchall() + for settingid, key in rows: + newkey = key.replace(old_marker, new_marker) + if newkey == key: + continue + exists = bind.exec_driver_sql( + "SELECT 1 FROM settings WHERE `key` = %s", (newkey,) + ).fetchone() + if exists: + bind.exec_driver_sql( + "DELETE FROM settings WHERE settingid = %s", (settingid,)) + else: + bind.exec_driver_sql( + "UPDATE settings SET `key` = %s WHERE settingid = %s", + (newkey, settingid)) + + +def _rename_permissions(bind, old_prefix, new_prefix, old_category, + new_category): + """Rename permission rows in place so role links survive, collision-safe.""" + rows = bind.exec_driver_sql( + "SELECT permissionid, name FROM permissions" + ).fetchall() + for permissionid, name in rows: + if not name.startswith(old_prefix): + continue + newname = new_prefix + name[len(old_prefix):] + exists = bind.exec_driver_sql( + "SELECT 1 FROM permissions WHERE name = %s", (newname,) + ).fetchone() + if exists: + bind.exec_driver_sql( + "DELETE FROM permissions WHERE permissionid = %s", + (permissionid,)) + else: + bind.exec_driver_sql( + "UPDATE permissions SET name = %s, category = %s " + "WHERE permissionid = %s", + (newname, new_category, permissionid)) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + + if ('alembic_version_machines' in tables + and 'alembic_version_equipment' not in tables): + bind.exec_driver_sql( + "RENAME TABLE alembic_version_machines TO alembic_version_equipment" + ) + + if 'settings' in tables: + _rename_setting_keys(bind, old_marker='_machine_', + new_marker='_equipment_') + if 'permissions' in tables: + _rename_permissions(bind, old_prefix='machines.', + new_prefix='equipment.', + old_category='machines', new_category='equipment') + if 'auditlogs' in tables: + bind.exec_driver_sql( + "UPDATE auditlogs SET entitytype='Equipment' " + "WHERE entitytype='Machine'" + ) + if 'assettypes' in tables: + bind.exec_driver_sql( + "UPDATE assettypes SET assettype='equipment', " + "pluginname='equipment', tablename='equipment' " + "WHERE assettype='machine'" + ) + + if 'modeltypes' in tables and 'machinetypes' not in tables: + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + for name in _fk_names(insp, 'models', 'modeltypes'): + bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}") + model_indexes = {ix['name'] for ix in insp.get_indexes('models')} + if 'modeltypeid' in model_indexes: + bind.exec_driver_sql("ALTER TABLE models DROP KEY modeltypeid") + bind.exec_driver_sql("RENAME TABLE modeltypes TO machinetypes") + bind.exec_driver_sql( + "ALTER TABLE machinetypes " + "CHANGE modeltypeid machinetypeid INT NOT NULL AUTO_INCREMENT, " + "CHANGE modeltype machinetype VARCHAR(100) NOT NULL, " + "DROP KEY modeltype, " + "ADD UNIQUE KEY machinetype (machinetype)" + ) + bind.exec_driver_sql( + "ALTER TABLE models " + "CHANGE modeltypeid machinetypeid INT NULL, " + "ADD KEY machinetypeid (machinetypeid), " + "ADD CONSTRAINT models_ibfk_1 " + "FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)" + ) + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") diff --git a/migrations/versions/7d18_supportteamcontacts.py b/migrations/versions/7d18_supportteamcontacts.py new file mode 100644 index 00000000..74fdc314 --- /dev/null +++ b/migrations/versions/7d18_supportteamcontacts.py @@ -0,0 +1,150 @@ +"""Support teams get contacts; drop the legacy appowners pair + +Restructures the application support model: supportteams keeps teamname (now +unique) + teamurl (widened to TEXT for ServiceNow group deep links), gains a +child supportteamcontacts table, and sheds its single-owner appownerid FK and +the appowners table. Each legacy team's appowner is migrated into ONE contact. +applications.supportteamid is unchanged (it already points at supportteams). + +Idempotent guards throughout so it is safe on a partially-migrated box. + +Revision ID: 7d18_supportteamcontacts +Revises: 7d17_machines_rename +Create Date: 2026-07-11 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d18_supportteamcontacts' +down_revision = '7d17_machines_rename' +branch_labels = None +depends_on = None + + +def _has_table(insp, name): + return insp.has_table(name) + + +def _has_column(insp, table, column): + return column in [c['name'] for c in insp.get_columns(table)] + + +def _has_unique(insp, table, name): + return name in [u['name'] for u in insp.get_unique_constraints(table)] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + # 1. New contacts table. + if not _has_table(insp, 'supportteamcontacts'): + op.create_table( + 'supportteamcontacts', + sa.Column('contactid', sa.Integer(), primary_key=True), + sa.Column('supportteamid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('sso', sa.String(length=50), nullable=True), + sa.Column('sortorder', sa.Integer(), nullable=False, + server_default='0'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default='1'), + sa.ForeignKeyConstraint( + ['supportteamid'], ['supportteams.supportteamid'], + ondelete='CASCADE'), + ) + + # 2. Migrate each team's appowner into one contact. + if _has_table(insp, 'appowners') and \ + _has_column(insp, 'supportteams', 'appownerid'): + bind.exec_driver_sql( + "INSERT INTO supportteamcontacts " + "(supportteamid, name, sso, sortorder, isactive, " + " createddate, modifieddate) " + "SELECT st.supportteamid, ao.appowner, ao.sso, 0, 1, " + " NOW(), NOW() " + "FROM supportteams st " + "JOIN appowners ao ON st.appownerid = ao.appownerid " + "WHERE st.appownerid IS NOT NULL") + + # 3. teamname becomes unique. + if not _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'): + op.create_unique_constraint( + 'uq_supportteams_teamname', 'supportteams', ['teamname']) + + # 4. Widen teamurl to TEXT. + op.alter_column('supportteams', 'teamurl', + existing_type=sa.String(length=255), + type_=sa.Text(), existing_nullable=True) + + # 5. Drop the appownerid FK + column, then the appowners table. + if _has_column(insp, 'supportteams', 'appownerid'): + for fk in insp.get_foreign_keys('supportteams'): + if 'appownerid' in fk['constrained_columns'] and fk.get('name'): + bind.exec_driver_sql( + f"ALTER TABLE supportteams DROP FOREIGN KEY {fk['name']}") + op.drop_column('supportteams', 'appownerid') + bind.exec_driver_sql("DROP TABLE IF EXISTS appowners") + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + # Recreate appowners. + if not _has_table(insp, 'appowners'): + op.create_table( + 'appowners', + sa.Column('appownerid', sa.Integer(), primary_key=True), + sa.Column('appowner', sa.String(length=100), nullable=False), + sa.Column('sso', sa.String(length=50), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default='1'), + ) + + # Re-add the appownerid FK column. + if not _has_column(insp, 'supportteams', 'appownerid'): + op.add_column('supportteams', + sa.Column('appownerid', sa.Integer(), nullable=True)) + op.create_foreign_key( + 'fk_supportteams_appownerid', 'supportteams', 'appowners', + ['appownerid'], ['appownerid']) + + # Best-effort data reversal: each team's first active contact -> an owner. + if _has_table(insp, 'supportteamcontacts'): + rows = bind.execute(sa.text( + "SELECT supportteamid, name, sso FROM supportteamcontacts " + "WHERE isactive = 1 ORDER BY supportteamid, sortorder, contactid")) + seen = set() + for row in rows: + if row.supportteamid in seen: + continue + seen.add(row.supportteamid) + result = bind.execute(sa.text( + "INSERT INTO appowners " + "(appowner, sso, isactive, createddate, modifieddate) " + "VALUES (:name, :sso, 1, NOW(), NOW())"), + {'name': row.name, 'sso': row.sso}) + bind.execute(sa.text( + "UPDATE supportteams SET appownerid = :ownerid " + "WHERE supportteamid = :teamid"), + {'ownerid': result.lastrowid, 'teamid': row.supportteamid}) + + # Narrow teamurl back to VARCHAR(255). + op.alter_column('supportteams', 'teamurl', + existing_type=sa.Text(), + type_=sa.String(length=255), existing_nullable=True) + + # Drop the unique constraint. + if _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'): + op.drop_constraint('uq_supportteams_teamname', 'supportteams', + type_='unique') + + op.drop_table('supportteamcontacts') diff --git a/migrations/versions/7d19_relationshiptype_directional.py b/migrations/versions/7d19_relationshiptype_directional.py new file mode 100644 index 00000000..c3bbd783 --- /dev/null +++ b/migrations/versions/7d19_relationshiptype_directional.py @@ -0,0 +1,70 @@ +"""Relationship types get isdirectional; flip symmetric legacy types + +Adds relationshiptypes.isdirectional (default true). The asset relationships +card uses it: directional types keep a source->target reading, symmetric types +(physical links, network connections) collapse both stored direction rows into +one direction-blind "connected" entry per peer. + +Seeds isdirectional=false for the connection-like types present pre-1.0: +Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet, USB, +WiFi. Everything else (controls, Controlled By, Backup For, Master-Slave, +partof, defaultprinter) stays directional. + +Idempotent guards so it is safe on a partially-migrated box. + +Revision ID: 7d19_relationshiptype_directional +Revises: 7d18_supportteamcontacts +Create Date: 2026-07-11 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d19_relationshiptype_directional' +down_revision = '7d18_supportteamcontacts' +branch_labels = None +depends_on = None + + +# names seeded symmetric (isdirectional=false) +SYMMETRIC_TYPES = [ + 'Dualpath', + 'connectedto', + 'Cluster Member', + 'Serial Cable', + 'Direct Ethernet', + 'USB', + 'WiFi', +] + + +def _has_column(insp, table, column): + return column in [c['name'] for c in insp.get_columns(table)] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if not _has_column(insp, 'relationshiptypes', 'isdirectional'): + op.add_column( + 'relationshiptypes', + sa.Column('isdirectional', sa.Boolean(), nullable=False, + server_default='1')) + + # flip the known symmetric types. re-running just re-sets the same values. + placeholders = ', '.join([f':n{i}' for i in range(len(SYMMETRIC_TYPES))]) + params = {f'n{i}': name for i, name in enumerate(SYMMETRIC_TYPES)} + bind.execute( + sa.text( + "UPDATE relationshiptypes SET isdirectional = 0 " + f"WHERE relationshiptype IN ({placeholders})"), + params) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if _has_column(insp, 'relationshiptypes', 'isdirectional'): + op.drop_column('relationshiptypes', 'isdirectional') diff --git a/migrations/versions/7d20_relationshiptypepropagations.py b/migrations/versions/7d20_relationshiptypepropagations.py new file mode 100644 index 00000000..c22498b9 --- /dev/null +++ b/migrations/versions/7d20_relationshiptypepropagations.py @@ -0,0 +1,109 @@ +"""Relationship-type propagation goes M:N (relationshiptypepropagations) + +Replaces the single-valued relationshiptypes.propagatesthroughid with a join +table so one type can propagate through several through-types at once. On this +DB `controls` needs both `partof` (declared) and `Dualpath` (consumed: a +dual-bay pair is one physical machine with a single controller, so both bays +must carry the controls link to that PC). + +Upgrade: create relationshiptypepropagations, migrate the existing +propagatesthroughid value into a row, drop the column, then seed the +controls -> Dualpath row (resolved by name, skipped if either type missing). +Downgrade: recreate the column from the first propagation row per type (best +effort) and drop the table. + +Idempotent guards so it is safe on a partially-migrated box. + +Revision ID: 7d20_relationshiptypepropagations +Revises: 7d19_relationshiptype_directional +Create Date: 2026-07-11 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d20_relationshiptypepropagations' +down_revision = '7d19_relationshiptype_directional' +branch_labels = None +depends_on = None + + +def _has_column(insp, table, column): + return column in [c['name'] for c in insp.get_columns(table)] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'relationshiptypepropagations' not in insp.get_table_names(): + op.create_table( + 'relationshiptypepropagations', + sa.Column('propagationid', sa.Integer(), primary_key=True), + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('throughtypeid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['relationshiptypeid'], + ['relationshiptypes.relationshiptypeid']), + sa.ForeignKeyConstraint(['throughtypeid'], + ['relationshiptypes.relationshiptypeid']), + sa.UniqueConstraint('relationshiptypeid', 'throughtypeid', + name='uq_reltype_propagation'), + ) + + if _has_column(insp, 'relationshiptypes', 'propagatesthroughid'): + # migrate the single-FK value into a row (dedup guard for re-runs) + bind.execute(sa.text( + "INSERT INTO relationshiptypepropagations " + "(relationshiptypeid, throughtypeid) " + "SELECT rt.relationshiptypeid, rt.propagatesthroughid " + "FROM relationshiptypes rt " + "WHERE rt.propagatesthroughid IS NOT NULL AND NOT EXISTS (" + " SELECT 1 FROM relationshiptypepropagations p " + " WHERE p.relationshiptypeid = rt.relationshiptypeid " + " AND p.throughtypeid = rt.propagatesthroughid)")) + # drop the self-FK (known name from 7a01, else reflect) then the column + fk_names = [fk['name'] for fk in insp.get_foreign_keys('relationshiptypes') + if fk.get('constrained_columns') == ['propagatesthroughid'] + and fk.get('name')] + for name in fk_names: + op.drop_constraint(name, 'relationshiptypes', type_='foreignkey') + op.drop_column('relationshiptypes', 'propagatesthroughid') + + # seed controls -> Dualpath by name; skip if either type is missing + rows = bind.execute(sa.text( + "SELECT relationshiptypeid, relationshiptype FROM relationshiptypes")).fetchall() + byname = {r[1]: r[0] for r in rows} + controls = byname.get('controls') + dualpath = byname.get('Dualpath') + if controls and dualpath: + exists = bind.execute(sa.text( + "SELECT 1 FROM relationshiptypepropagations " + "WHERE relationshiptypeid = :c AND throughtypeid = :d"), + {'c': controls, 'd': dualpath}).fetchone() + if not exists: + bind.execute(sa.text( + "INSERT INTO relationshiptypepropagations " + "(relationshiptypeid, throughtypeid) VALUES (:c, :d)"), + {'c': controls, 'd': dualpath}) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if not _has_column(insp, 'relationshiptypes', 'propagatesthroughid'): + op.add_column('relationshiptypes', + sa.Column('propagatesthroughid', sa.Integer(), nullable=True)) + op.create_foreign_key('fk_relationshiptype_propagation', + 'relationshiptypes', 'relationshiptypes', + ['propagatesthroughid'], ['relationshiptypeid']) + + if 'relationshiptypepropagations' in insp.get_table_names(): + # best effort: restore the first propagation row per type + bind.execute(sa.text( + "UPDATE relationshiptypes rt SET propagatesthroughid = (" + " SELECT p.throughtypeid FROM relationshiptypepropagations p " + " WHERE p.relationshiptypeid = rt.relationshiptypeid " + " ORDER BY p.propagationid LIMIT 1)")) + op.drop_table('relationshiptypepropagations') diff --git a/migrations/versions/7d21_apitokens.py b/migrations/versions/7d21_apitokens.py new file mode 100644 index 00000000..72e373ff --- /dev/null +++ b/migrations/versions/7d21_apitokens.py @@ -0,0 +1,61 @@ +"""Personal API tokens (apitokens) + +Adds the apitokens table: personal access tokens that let scripts and +integrations authenticate as a user without the hourly-expiring login JWT. +Only the sha256 hash of each secret is stored. + +Idempotent guard so it is safe on a partially-migrated box; real downgrade. + +Revision ID: 7d21_apitokens +Revises: 7d20_relationshiptypepropagations +Create Date: 2026-07-12 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d21_apitokens' +down_revision = '7d20_relationshiptypepropagations' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'apitokens' in insp.get_table_names(): + return + + op.create_table( + 'apitokens', + sa.Column('tokenid', sa.Integer(), primary_key=True), + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('tokenprefix', sa.String(length=16), nullable=True), + sa.Column('tokenhash', sa.String(length=64), nullable=False), + sa.Column('expiresat', sa.DateTime(), nullable=True), + sa.Column('lastusedat', sa.DateTime(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'), + sa.ForeignKeyConstraint(['userid'], ['users.userid']), + sa.UniqueConstraint('tokenhash', name='uq_apitoken_tokenhash'), + ) + op.create_index('ix_apitokens_userid', 'apitokens', ['userid']) + op.create_index('ix_apitokens_tokenprefix', 'apitokens', ['tokenprefix']) + op.create_index('ix_apitokens_tokenhash', 'apitokens', ['tokenhash']) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'apitokens' not in insp.get_table_names(): + return + + op.drop_index('ix_apitokens_tokenhash', table_name='apitokens') + op.drop_index('ix_apitokens_tokenprefix', table_name='apitokens') + op.drop_index('ix_apitokens_userid', table_name='apitokens') + op.drop_table('apitokens') diff --git a/migrations/versions/7d22_apitokens_scopes.py b/migrations/versions/7d22_apitokens_scopes.py new file mode 100644 index 00000000..d12ecd11 --- /dev/null +++ b/migrations/versions/7d22_apitokens_scopes.py @@ -0,0 +1,49 @@ +"""API token permission scopes (apitokens.scopes) + +Adds a nullable scopes column to apitokens: a JSON array of permission-name +strings. NULL means the token is unscoped and acts with the full authority of +its owner (the original behavior). A scoped token grants ONLY the listed +permissions, intersected with what the owner actually holds, and suspends the +admin-role bypass so a scoped token minted by an admin is genuinely limited. + +Idempotent guard so it is safe on a partially-migrated box; real downgrade. + +Revision ID: 7d22_apitokens_scopes +Revises: 7d21_apitokens +Create Date: 2026-07-12 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d22_apitokens_scopes' +down_revision = '7d21_apitokens' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'apitokens' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('apitokens')} + if 'scopes' in columns: + return + + op.add_column('apitokens', sa.Column('scopes', sa.Text(), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'apitokens' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('apitokens')} + if 'scopes' not in columns: + return + + op.drop_column('apitokens', 'scopes') diff --git a/migrations/versions/7d23_user_mustchangepassword.py b/migrations/versions/7d23_user_mustchangepassword.py new file mode 100644 index 00000000..de7eeebc --- /dev/null +++ b/migrations/versions/7d23_user_mustchangepassword.py @@ -0,0 +1,50 @@ +"""User forced-password-change flag (users.mustchangepassword) + +Adds a boolean users.mustchangepassword column (default false). An admin who +creates a user sets it true so the user is forced through a password change on +first login; changing the password clears it. + +Idempotent guard so it is safe on a partially-migrated box; real downgrade. + +Revision ID: 7d23_user_mustchangepassword +Revises: 7d22_apitokens_scopes +Create Date: 2026-07-12 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d23_user_mustchangepassword' +down_revision = '7d22_apitokens_scopes' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'users' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('users')} + if 'mustchangepassword' in columns: + return + + op.add_column( + 'users', + sa.Column('mustchangepassword', sa.Boolean(), + nullable=False, server_default=sa.false())) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'users' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('users')} + if 'mustchangepassword' not in columns: + return + + op.drop_column('users', 'mustchangepassword') diff --git a/migrations/versions/7d24_customfield_searchable.py b/migrations/versions/7d24_customfield_searchable.py new file mode 100644 index 00000000..bb0b3fef --- /dev/null +++ b/migrations/versions/7d24_customfield_searchable.py @@ -0,0 +1,50 @@ +"""Custom field searchable flag (customfields.searchable) + +Adds a boolean customfields.searchable column (default false). When true, the +field's stored values are matched by global search and a hit routes to the +owning asset's detail page. + +Idempotent guard so it is safe on a partially-migrated box; real downgrade. + +Revision ID: 7d24_customfield_searchable +Revises: 7d23_user_mustchangepassword +Create Date: 2026-07-12 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d24_customfield_searchable' +down_revision = '7d23_user_mustchangepassword' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'customfields' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('customfields')} + if 'searchable' in columns: + return + + op.add_column( + 'customfields', + sa.Column('searchable', sa.Boolean(), + nullable=False, server_default=sa.false())) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'customfields' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('customfields')} + if 'searchable' not in columns: + return + + op.drop_column('customfields', 'searchable') diff --git a/migrations/versions/7d25_drop_redundant_indexes.py b/migrations/versions/7d25_drop_redundant_indexes.py new file mode 100644 index 00000000..a4c49a86 --- /dev/null +++ b/migrations/versions/7d25_drop_redundant_indexes.py @@ -0,0 +1,57 @@ +"""Drop redundant secondary indexes duplicating a named idx_* / unique index. + +The DB review found five non-unique secondary indexes that duplicate an existing +index on the same column: the auto-named ix_* from a column index=True alongside +a named idx_* in __table_args__ (communications.assetid, and the hostname on +computers/networkdevices/printers), plus idx_usb_serial duplicating the +serialnumber unique index on usbdevices. Each redundant pair costs insert/update +maintenance with zero read benefit. The models had the redundant index source +removed; this drops the live duplicates so the schema matches the models. + +Idempotent (skips a missing table/index); real downgrade recreates them. + +Revision ID: 7d25_drop_redundant_indexes +Revises: 7d24_customfield_searchable +Create Date: 2026-07-13 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d25_drop_redundant_indexes' +down_revision = '7d24_customfield_searchable' +branch_labels = None +depends_on = None + +# (table, index name, columns, unique) - columns/unique used only to recreate on +# downgrade. All non-unique; the surviving idx_* / unique index still covers each. +_REDUNDANT = [ + ('communications', 'ix_communications_assetid', ['assetid'], False), + ('computers', 'ix_computers_hostname', ['hostname'], False), + ('networkdevices', 'ix_networkdevices_hostname', ['hostname'], False), + ('printers', 'ix_printers_hostname', ['hostname'], False), + ('usbdevices', 'idx_usb_serial', ['serialnumber'], False), +] + + +def _index_names(insp, table): + return {i['name'] for i in insp.get_indexes(table)} + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + for table, name, _cols, _unique in _REDUNDANT: + if table in tables and name in _index_names(insp, table): + op.drop_index(name, table_name=table) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + for table, name, cols, unique in _REDUNDANT: + if table in tables and name not in _index_names(insp, table): + op.create_index(name, table, cols, unique=unique) diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 00000000..0c42fb27 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +"""ShopDB plugins package.""" diff --git a/plugins/computers/__init__.py b/plugins/computers/__init__.py new file mode 100644 index 00000000..c96a5374 --- /dev/null +++ b/plugins/computers/__init__.py @@ -0,0 +1,5 @@ +"""Computers plugin for ShopDB.""" + +from .plugin import ComputersPlugin + +__all__ = ['ComputersPlugin'] diff --git a/plugins/computers/api/__init__.py b/plugins/computers/api/__init__.py new file mode 100644 index 00000000..0bea25bc --- /dev/null +++ b/plugins/computers/api/__init__.py @@ -0,0 +1,5 @@ +"""Computers plugin API.""" + +from .routes import computers_bp + +__all__ = ['computers_bp'] diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py new file mode 100644 index 00000000..e028e24e --- /dev/null +++ b/plugins/computers/api/routes.py @@ -0,0 +1,894 @@ +"""Computers plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query + +from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess + +from shopdb.api import require_permission, apply_import_timestamps + +computers_bp = Blueprint('computers', __name__) + + +# ============================================================================= +# Computer Types +# ============================================================================= + +@computers_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_computer_types(): + """List all computer types.""" + page, per_page = get_pagination_params(request) + + query = ComputerType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(ComputerType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(ComputerType.computertype.ilike(f'%{search}%')) + + query = query.order_by(ComputerType.computertype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@computers_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_computer_type(type_id: int): + """Get a single computer type.""" + t = db.session.get(ComputerType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@computers_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('computers.create') +def create_computer_type(): + """Create a new computer type.""" + data = request.get_json() + + if not data or not data.get('computertype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'computertype is required') + + existing = ComputerType.query.filter_by(computertype=data['computertype']).first() + if existing: + if not existing.isactive: + # Adding a name that matches a deactivated type revives it. + existing.isactive = True + for key in ('description', 'icon', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing computer type') + return error_response( + ErrorCodes.CONFLICT, + f"Computer type '{data['computertype']}' already exists", + http_code=409 + ) + + t = ComputerType( + computertype=data['computertype'], + description=data.get('description'), + icon=data.get('icon'), color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Computer type created', http_code=201) + + +@computers_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('computers.edit') +def update_computer_type(type_id: int): + """Update a computer type.""" + t = db.session.get(ComputerType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'computertype' in data and data['computertype'] != t.computertype: + if ComputerType.query.filter_by(computertype=data['computertype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Computer type '{data['computertype']}' already exists", + http_code=409 + ) + + for key in ['computertype', 'description', 'icon', 'color', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Computer type updated') + + +@computers_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('computers.delete') +def delete_computer_type(type_id: int): + """Delete a computer type. Refused if any PC still uses it.""" + t = db.session.get(ComputerType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Computer type not found', http_code=404) + inuse = Computer.query.filter_by(computertypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} PC(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Computer type deleted') + + +# ============================================================================= +# Access protocol catalog (VNC / WinRM / RDP / ...) - admin-managed +# ============================================================================= + +@computers_bp.route('/protocols', methods=['GET']) +@jwt_required(optional=True) +def list_protocols(): + """List access protocols. ?active=false includes disabled ones.""" + query = AccessProtocol.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AccessProtocol.isactive == True) + protocols = query.order_by(AccessProtocol.name).all() + return success_response([p.to_dict() for p in protocols]) + + +@computers_bp.route('/protocols', methods=['POST']) +@jwt_required() +@require_permission('computers.edit') +def create_protocol(): + data = request.get_json() or {} + if not (data.get('name') and data.get('scheme') and data.get('linktemplate')): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name, scheme and linktemplate are required') + if AccessProtocol.query.filter_by(name=data['name']).first(): + return error_response(ErrorCodes.CONFLICT, f"Protocol '{data['name']}' already exists", http_code=409) + p = AccessProtocol( + name=data['name'], + scheme=data['scheme'], + defaultport=data.get('defaultport') or None, + linktemplate=data['linktemplate'], + isactive=data.get('isactive', True), + ) + db.session.add(p) + db.session.commit() + return success_response(p.to_dict(), message='Protocol created', http_code=201) + + +@computers_bp.route('/protocols/', methods=['PUT', 'PATCH']) +@jwt_required() +@require_permission('computers.edit') +def update_protocol(protocol_id): + p = db.session.get(AccessProtocol, protocol_id) + if not p: + return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404) + data = request.get_json() or {} + for field in ('name', 'scheme', 'linktemplate'): + if data.get(field): + setattr(p, field, data[field]) + if 'defaultport' in data: + p.defaultport = data['defaultport'] or None + if 'isactive' in data: + p.isactive = bool(data['isactive']) + db.session.commit() + return success_response(p.to_dict(), message='Protocol updated') + + +@computers_bp.route('/protocols/', methods=['DELETE']) +@jwt_required() +@require_permission('computers.edit') +def delete_protocol(protocol_id): + p = db.session.get(AccessProtocol, protocol_id) + if not p: + return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404) + # If any PC still references it, deactivate rather than hard-delete. + if ComputerAccess.query.filter_by(protocolid=protocol_id).first(): + p.isactive = False + db.session.commit() + return success_response(message='Protocol is in use; deactivated instead of deleted') + db.session.delete(p) + db.session.commit() + return success_response(message='Protocol deleted') + + +def _pc_access_domain(): + # Contract-pure read of the pc_access_domain setting (no core.api import). + row = Setting.query.filter_by(key='pc_access_domain').first() + return ((row.value if row else '') or '').strip() + + +def _computer_access_links(comp, domain=None): + """Resolved remote-access links for a computer: each enabled protocol's + template filled with the PC hostname joined to the pc_access_domain setting. + A hostname that is already an FQDN (has a dot) is used as-is. Pass domain + when calling in a loop to avoid one settings lookup per computer.""" + if domain is None: + domain = _pc_access_domain() + hostname = (comp.hostname or '').strip() + if not hostname: + host = '' + elif '.' in hostname or not domain: + host = hostname + else: + host = f"{hostname}.{domain}" + + links = [] + for am in comp.accessmethods: + protocol = am.protocol + if not (am.isactive and protocol and protocol.isactive): + continue + port = am.portoverride or protocol.defaultport + link = None + if host: + try: + link = protocol.linktemplate.format( + host=host, + port=(port if port is not None else ''), + scheme=protocol.scheme, + ) + except (KeyError, IndexError, ValueError): + link = None + links.append({ + 'id': am.id, + 'protocolid': protocol.protocolid, + 'name': protocol.name, + 'scheme': protocol.scheme, + 'port': port, + 'portoverride': am.portoverride, + 'link': link, + }) + return links + + +def _sync_access_methods(comp, data): + """Replace a computer's enabled protocols from data['accessmethods'] (a list + of {protocolid, portoverride?}). No-op if the key is absent, so callers that + don't touch access aren't affected.""" + if 'accessmethods' not in data: + return + desired = data.get('accessmethods') or [] + ComputerAccess.query.filter_by(computerid=comp.computerid).delete() + seen = set() + for m in desired: + try: + pid = int(m.get('protocolid')) + except (TypeError, ValueError): + continue + if pid in seen: + continue + seen.add(pid) + port = m.get('portoverride') + try: + port = int(port) if port not in (None, '') else None + except (TypeError, ValueError): + port = None + db.session.add(ComputerAccess( + computerid=comp.computerid, + protocolid=pid, + portoverride=port, + isactive=True, + )) + + +# ============================================================================= +# Computers CRUD +# ============================================================================= + +@computers_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_computers(): + """ + List all computers with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by computer type ID + - os_id: Filter by operating system ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - shopfloor: Filter by shopfloor flag (true/false) + """ + page, per_page = get_pagination_params(request) + + # Join Computer with Asset + query = db.session.query(Computer).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + Computer.hostname.ilike(f'%{search}%') + ) + ) + + # Computer type filter + 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('osid', request.args.get('os_id')): + query = query.filter(Computer.osid == int(os_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Shopfloor filter (by the Shopfloor computer type) + if shopfloor := request.args.get('shopfloor'): + sf = ComputerType.query.filter_by(computertype='Shopfloor').first() + sf_id = sf.computertypeid if sf else -1 + if shopfloor.lower() == 'true': + query = query.filter(Computer.computertypeid == sf_id) + else: + query = query.filter(db.or_(Computer.computertypeid != sf_id, + Computer.computertypeid.is_(None))) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = Computer.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + elif sort_by == 'lastreporteddate': + col = Computer.lastreporteddate + else: + col = Computer.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and computer data + data = [] + accessdomain = _pc_access_domain() + for comp in items: + item = comp.asset.to_dict() if comp.asset else {} + item['computer'] = comp.to_dict() + item['accessmethods'] = _computer_access_links(comp, domain=accessdomain) + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@computers_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_computer(computer_id: int): + """Get a single computer with full details.""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + 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() + ] + result['accessmethods'] = _computer_access_links(comp) + + return success_response(result) + + +@computers_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_computer_by_asset(asset_id: int): + """Get computer data by asset ID.""" + comp = Computer.query.filter_by(assetid=asset_id).first() + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer for asset {asset_id} not found', + http_code=404 + ) + + result = comp.asset.to_dict() if comp.asset else {} + result['computer'] = comp.to_dict() + + return success_response(result) + + +@computers_bp.route('/by-hostname/', methods=['GET']) +@jwt_required(optional=True) +def get_computer_by_hostname(hostname: str): + """Get computer by hostname.""" + comp = Computer.query.filter_by(hostname=hostname).first() + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with hostname {hostname} not found', + http_code=404 + ) + + result = comp.asset.to_dict() if comp.asset else {} + result['computer'] = comp.to_dict() + + return success_response(result) + + +@computers_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('computers.create') +def create_computer(): + """ + Create new computer (creates both Asset and Computer records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - computertypeid, hostname, osid + - mapx, mapy, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for duplicate hostname + if data.get('hostname'): + if Computer.query.filter_by(hostname=data['hostname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Computer with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Get computer asset type + computer_type = AssetType.query.filter_by(assettype='computer').first() + if not computer_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Computer asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + assettypeid=computer_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the computer extension + comp = Computer( + assetid=asset.assetid, + 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') + ) + + 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, + )) + + # Remote-access protocols + _sync_access_methods(comp, data) + + # Preserve legacy timestamps in import mode (no-op otherwise) + apply_import_timestamps(asset, data) + + # Audit log + AuditLog.log('created', 'Computer', entityid=comp.computerid, + entityname=data.get('hostname') or data['assetnumber']) + + db.session.commit() + + result = asset.to_dict() + result['computer'] = comp.to_dict() + result['accessmethods'] = _computer_access_links(comp) + + return success_response(result, message='Computer created', http_code=201) + + +@computers_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('computers.edit') +def update_computer(computer_id: int): + """Update computer (both Asset and Computer records).""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = comp.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for conflicting hostname + if 'hostname' in data and data['hostname'] != comp.hostname: + existing = Computer.query.filter_by(hostname=data['hostname']).first() + if existing and existing.computerid != computer_id: + return error_response( + ErrorCodes.CONFLICT, + f"Computer with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Track changes for audit log + changes = {} + + # Update asset fields + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] + for key in asset_fields: + if key in data: + old_val = getattr(asset, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(asset, key, data[key]) + + # Update computer fields + computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid', + 'modelnumberid', 'loggedinuser', 'lastreporteddate', + 'lastboottime'] + for key in computer_fields: + if key in data: + old_val = getattr(comp, key) + new_val = data[key] + if old_val != new_val: + 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 + + # Remote-access protocols + _sync_access_methods(comp, data) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'Computer', entityid=comp.computerid, + entityname=comp.hostname or asset.assetnumber, changes=changes) + + apply_import_timestamps(asset, data) + db.session.commit() + + result = asset.to_dict() + result['computer'] = comp.to_dict() + result['accessmethods'] = _computer_access_links(comp) + + return success_response(result, message='Computer updated') + + +@computers_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('computers.delete') +def delete_computer(computer_id: int): + """Delete (soft delete) computer.""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + # Soft delete the asset + comp.asset.isactive = False + + # Audit log + AuditLog.log('deleted', 'Computer', entityid=comp.computerid, + entityname=comp.hostname or comp.asset.assetnumber) + + db.session.commit() + + return success_response(message='Computer deleted') + + +# ============================================================================= +# Installed Applications +# ============================================================================= + +@computers_bp.route('//apps', methods=['GET']) +@jwt_required(optional=True) +def get_installed_apps(computer_id: int): + """Get all installed applications for a computer.""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + apps = ComputerInstalledApp.query.filter_by( + computerid=computer_id, + isactive=True + ).all() + + data = [app.to_dict() for app in apps] + + return success_response(data) + + +@computers_bp.route('//apps', methods=['POST']) +@jwt_required() +@require_permission('computers.create') +def add_installed_app(computer_id: int): + """Add an installed application to a computer.""" + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data or not data.get('appid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required') + + appid = data['appid'] + + # Validate app exists + if not db.session.get(Application, appid): + return error_response(ErrorCodes.NOT_FOUND, f'Application {appid} not found', http_code=404) + + # Check for duplicate + existing = ComputerInstalledApp.query.filter_by( + computerid=computer_id, + appid=appid + ).first() + + if existing: + if existing.isactive: + return error_response( + ErrorCodes.CONFLICT, + 'This application is already installed on this computer', + http_code=409 + ) + else: + # Reactivate + existing.isactive = True + existing.appversionid = data.get('appversionid') + db.session.commit() + return success_response(existing.to_dict(), message='Application reinstalled') + + # Create new installation record + installed = ComputerInstalledApp( + computerid=computer_id, + appid=appid, + appversionid=data.get('appversionid') + ) + + db.session.add(installed) + db.session.commit() + + return success_response(installed.to_dict(), message='Application installed', http_code=201) + + +@computers_bp.route('//apps/', methods=['DELETE']) +@jwt_required() +@require_permission('computers.delete') +def remove_installed_app(computer_id: int, app_id: int): + """Remove an installed application from a computer.""" + installed = ComputerInstalledApp.query.filter_by( + computerid=computer_id, + appid=app_id, + isactive=True + ).first() + + if not installed: + return error_response( + ErrorCodes.NOT_FOUND, + 'Installation record not found', + http_code=404 + ) + + installed.isactive = False + db.session.commit() + + return success_response(message='Application uninstalled') + + +# ============================================================================= +# Status Reporting +# ============================================================================= + +@computers_bp.route('//report', methods=['POST']) +@jwt_required() +@require_permission('computers.create') +def report_status(computer_id: int): + """ + Report computer status (for agent-based reporting). + + This endpoint can be called periodically by a client agent + to update status information. + """ + comp = db.session.get(Computer, computer_id) + + if not comp: + return error_response( + ErrorCodes.NOT_FOUND, + f'Computer with ID {computer_id} not found', + http_code=404 + ) + + data = request.get_json() or {} + + # Update status fields + from datetime import datetime, timezone + comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None) + + if 'loggedinuser' in data: + comp.loggedinuser = data['loggedinuser'] + if 'lastboottime' in data: + comp.lastboottime = data['lastboottime'] + + db.session.commit() + + return success_response(message='Status reported') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@computers_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get computer dashboard summary data.""" + # Total active computers + total = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by computer type + by_type = db.session.query( + ComputerType.computertype, + db.func.count(Computer.computerid) + ).join(Computer, Computer.computertypeid == ComputerType.computertypeid + ).join(Asset, Asset.assetid == Computer.assetid + ).filter(Asset.isactive == True + ).group_by(ComputerType.computertype + ).all() + + # Count by OS + by_os = db.session.query( + OperatingSystem.osname, + db.func.count(Computer.computerid) + ).join(Computer, Computer.osid == OperatingSystem.osid + ).join(Asset, Asset.assetid == Computer.assetid + ).filter(Asset.isactive == True + ).group_by(OperatingSystem.osname + ).all() + + # Count shopfloor vs non-shopfloor + sf = ComputerType.query.filter_by(computertype='Shopfloor').first() + shopfloor_count = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True, + Computer.computertypeid == (sf.computertypeid if sf else -1) + ).count() + + return success_response({ + 'total': total, + 'bytype': [{'type': t, 'count': c} for t, c in by_type], + 'byos': [{'os': o, 'count': c} for o, c in by_os], + 'shopfloor': shopfloor_count, + 'nonshopfloor': total - shopfloor_count + }) diff --git a/plugins/computers/manifest.json b/plugins/computers/manifest.json new file mode 100644 index 00000000..c0ce3513 --- /dev/null +++ b/plugins/computers/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "computers", + "version": "1.0.0", + "description": "Computer management plugin for PCs, servers, and workstations with software tracking", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/computers", + "provides": { + "asset_type": "computer", + "features": [ + "computer_tracking", + "software_inventory", + "remote_access", + "os_management" + ] + }, + "settings": { + "enable_winrm": true, + "enable_vnc": true, + "auto_report_interval_hours": 24 + } +} diff --git a/plugins/computers/migrations/env.py b/plugins/computers/migrations/env.py new file mode 100644 index 00000000..03290a11 --- /dev/null +++ b/plugins/computers/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the computers plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_computers. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'computers' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/computers/migrations/script.py.mako b/plugins/computers/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/computers/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/computers/migrations/versions/0001_computers_anchor.py b/plugins/computers/migrations/versions/0001_computers_anchor.py new file mode 100644 index 00000000..9ad50307 --- /dev/null +++ b/plugins/computers/migrations/versions/0001_computers_anchor.py @@ -0,0 +1,28 @@ +"""computers plugin anchor (ownership cutover). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_computers. From this +anchor forward, new computers schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'computers0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/computers/models/__init__.py b/plugins/computers/models/__init__.py new file mode 100644 index 00000000..eee75210 --- /dev/null +++ b/plugins/computers/models/__init__.py @@ -0,0 +1,17 @@ +"""Computers plugin models.""" + +from .computer import ( + Computer, + ComputerType, + ComputerInstalledApp, + AccessProtocol, + ComputerAccess, +) + +__all__ = [ + 'Computer', + 'ComputerType', + 'ComputerInstalledApp', + 'AccessProtocol', + 'ComputerAccess', +] diff --git a/plugins/computers/models/computer.py b/plugins/computers/models/computer.py new file mode 100644 index 00000000..514fe2e5 --- /dev/null +++ b/plugins/computers/models/computer.py @@ -0,0 +1,284 @@ +"""Computer plugin models.""" + +from datetime import datetime, timezone + +from shopdb.api import db, BaseModel + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +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') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class Computer(BaseModel): + """ + Computer-specific extension data. + + Links to core Asset table via assetid. + Stores computer-specific fields like hostname, OS, logged in user, etc. + """ + __tablename__ = 'computers' + + computerid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Computer classification + computertypeid = db.Column( + db.Integer, + db.ForeignKey('computertypes.computertypeid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + comment='Network hostname' + ) + + # Operating system + osid = db.Column( + db.Integer, + db.ForeignKey('operatingsystems.osid'), + nullable=True + ) + + # Hardware make/model (PCs carry vendor + model like machines) + 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 is now modeled per-protocol via the accessmethods + # relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm. + + # 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' + ) + + # Remote-access protocols enabled on this PC (replaces isvnc/iswinrm) + accessmethods = db.relationship( + 'ComputerAccess', + back_populates='computer', + cascade='all, delete-orphan', + lazy='selectin' + ) + + __table_args__ = ( + db.Index('idx_computer_type', 'computertypeid'), + db.Index('idx_computer_hostname', 'hostname'), + db.Index('idx_computer_os', 'osid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.computertype: + result['computertypename'] = self.computertype.computertype + if self.operatingsystem: + result['osname'] = self.operatingsystem.osname + if self.vendor: + result['vendorname'] = self.vendor.vendor + if self.model: + result['modelname'] = self.model.modelnumber + if self.model.imageurl: + result['imageurl'] = self.model.imageurl + + # Names of enabled remote-access protocols (for list badges) + result['accessprotocolnames'] = [ + am.protocol.name for am in self.accessmethods + if am.isactive and am.protocol and am.protocol.isactive + ] + + 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=_utcnow) + + # 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): + # Curated AppVersion wins; else the raw collected version string. + version = None + if self.appversion is not None: + version = self.appversion.version + elif self.installedversion: + version = self.installedversion + return { + 'id': self.id, + 'computerid': self.computerid, + 'appid': self.appid, + 'appname': self.application.appname if self.application else None, + 'appdescription': self.application.appdescription if self.application else None, + 'appversionid': self.appversionid, + 'installedversion': version, + 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, + 'isactive': self.isactive, + } + + +class AccessProtocol(db.Model): + """ + Catalog of remote-access protocols a PC can expose (VNC, WinRM, RDP, SSH...). + + linktemplate builds a connection URL from placeholders {host}, {port}, + {scheme}. {host} is the PC hostname joined to the pc_access_domain setting. + Admin-managed; replaces the old fixed isvnc/iswinrm booleans. + """ + __tablename__ = 'accessprotocols' + + protocolid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + scheme = db.Column(db.String(20), nullable=False) + defaultport = db.Column(db.Integer, nullable=True) + linktemplate = db.Column(db.String(255), nullable=False) + isactive = db.Column(db.Boolean, default=True, nullable=False) + + def to_dict(self): + return { + 'protocolid': self.protocolid, + 'name': self.name, + 'scheme': self.scheme, + 'defaultport': self.defaultport, + 'linktemplate': self.linktemplate, + 'isactive': bool(self.isactive), + } + + +class ComputerAccess(db.Model): + """A protocol enabled on a specific PC, with an optional port override.""" + __tablename__ = 'computeraccess' + + id = db.Column(db.Integer, primary_key=True) + computerid = db.Column( + db.Integer, + db.ForeignKey('computers.computerid', ondelete='CASCADE'), + nullable=False + ) + protocolid = db.Column( + db.Integer, + db.ForeignKey('accessprotocols.protocolid'), + nullable=False + ) + portoverride = db.Column(db.Integer, nullable=True) + isactive = db.Column(db.Boolean, default=True, nullable=False) + + protocol = db.relationship('AccessProtocol') + computer = db.relationship('Computer', back_populates='accessmethods') + + __table_args__ = ( + db.UniqueConstraint('computerid', 'protocolid', name='uq_computer_protocol'), + db.Index('idx_compaccess_computer', 'computerid'), + ) + + def to_dict(self): + """Convert to dictionary.""" + return { + 'id': self.id, + 'computerid': self.computerid, + 'appid': self.appid, + 'appversionid': self.appversionid, + 'isactive': self.isactive, + 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, + 'application': { + 'appid': self.application.appid, + 'appname': self.application.appname, + 'appdescription': self.application.appdescription, + } if self.application else None, + 'version': self.appversion.version if self.appversion else None + } + + def __repr__(self): + return f"" diff --git a/plugins/computers/pctypemap.py b/plugins/computers/pctypemap.py new file mode 100644 index 00000000..320c3dca --- /dev/null +++ b/plugins/computers/pctypemap.py @@ -0,0 +1,68 @@ +"""PXE/GE-Enforce pc-type -> ComputerType mapping (computers-plugin domain). + +The imaging pipeline writes C:\\Enrollment\\pc-type.txt (the gea-shopfloor-* +taxonomy). The collector maps that to a flask ComputerType. Defaults live here; +per-site overrides live in core Settings (pctypemap_, category +'pctypemapping') and are editable in the Settings UI. This indirection means +the imaging taxonomy can change without code edits. +""" + +from shopdb.api import Setting + +# pxe pc-type -> flask ComputerType name. Defaults; overridable via settings. +DEFAULT_PCTYPE_MAP = { + 'gea-shopfloor-collections': 'Shopfloor PC', + 'gea-shopfloor-nocollections': 'Shopfloor PC', + 'gea-shopfloor-common': 'Shopfloor PC', + 'gea-shopfloor-keyence': 'Shopfloor PC', + 'gea-shopfloor-cmm': 'CMM PC', + 'gea-shopfloor-genspect': 'Shopfloor PC', + 'gea-shopfloor-heattreat': 'Shopfloor PC', + 'gea-shopfloor-waxtrace': 'Shopfloor PC', + 'gea-shopfloor-display': 'Kiosk', + 'gea-shopfloor-partmarker': 'Shopfloor PC', +} + +# DEPRECATED settings surface: the "Collector PC Types" settings page is retired +# (GE-Enforce scope computertypeid supersedes it, ADR-012). Collector still reads +# this map, so keep the code; only the editor UI card is gone. +_SETTING_PREFIX = 'pctypemap_' +_SETTING_CATEGORY = 'pctypemapping' + +# Metrology imaging pc-types: the PC itself stays a shopfloor PC, but it drives +# an attached measuring instrument. When the collector sees one of these, it +# auto-creates a MeasuringTool asset for the device and links the PC to it with +# a "controls" relationship (see ComputersPlugin._sync_measuringtool_link). +# Maps imaging pc-type -> (MeasuringToolType name, type description). The type +# is created on demand if the measuringtools plugin has not seeded it. +METROLOGY_TOOL_MAP = { + 'gea-shopfloor-cmm': ('CMM', 'Coordinate measuring machine'), + 'gea-shopfloor-keyence': ('Vision System', 'Optical / vision measurement system (Keyence)'), + 'gea-shopfloor-genspect': ('Genspect', 'Genspect visual / borescope inspection system'), + 'gea-shopfloor-waxtrace': ('Form Tracer', 'Surface / form tracing system (wax and trace)'), +} + + +def metrology_tool_for(pctype): + """Return (typename, typedescription) if this pc-type drives a measuring + tool, else None.""" + return METROLOGY_TOOL_MAP.get((pctype or '').strip()) + + +def pctype_mapping(): + """Return the live pctype -> ComputerType-name map (settings over defaults).""" + mapping = dict(DEFAULT_PCTYPE_MAP) + for setting in Setting.query.filter_by(category=_SETTING_CATEGORY).all(): + if setting.key.startswith(_SETTING_PREFIX): + mapping[setting.key[len(_SETTING_PREFIX):]] = setting.get_typed_value() + return mapping + + +def seed_pctype_settings(): + """Seed the pctypemap_ settings from defaults (idempotent).""" + for pxetype, computertype in DEFAULT_PCTYPE_MAP.items(): + key = f'{_SETTING_PREFIX}{pxetype}' + if not Setting.query.filter_by(key=key).first(): + Setting.set(key, computertype, valuetype='string', + category=_SETTING_CATEGORY, + description=f'Collector maps pc-type {pxetype} to this ComputerType') diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py new file mode 100644 index 00000000..fee77748 --- /dev/null +++ b/plugins/computers/plugin.py @@ -0,0 +1,680 @@ +"""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, AccessProtocol, ComputerAccess +from .api import computers_bp + +logger = logging.getLogger(__name__) + +# Marker stamped on PC->printer links this collector creates. The stale-link +# archive only touches rows carrying this label, so manually-created printer +# relationships are never removed by a collector push. Stored in the +# assetrelationships.label column (no origin column exists; see BUILD notes). +PRINTER_LINK_ORIGIN = 'collector:printers' + +# Marker stamped on PC->measuringtool "controls" links this collector creates +# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link +# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived +# by a collector push, so hand-made tool links survive. +MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool' + + +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, AccessProtocol, ComputerAccess] + + 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). + + Aligns with the GE-Enforce status shape (transport may change), using + the project naming convention (lowercase concatenated). The caller maps + its own field names to these. + """ + return { + 'identityfield': 'hostname', + 'fields': { + 'hostname': {'type': 'string', 'required': True}, + 'machinenumber': {'type': 'string'}, + 'pctype': {'type': 'string'}, + 'pcsubtype': {'type': 'string'}, + 'serialnumber': {'type': 'string'}, + 'loggedinuser': {'type': 'string'}, + 'lastboottime': {'type': 'string', 'format': 'date-time'}, + 'lastcheckin': {'type': 'string', 'format': 'date-time'}, + 'ipaddress': {'type': 'string'}, + 'vendorname': {'type': 'string'}, + 'modelnumber': {'type': 'string'}, + 'osname': {'type': 'string'}, + 'installedsoftware': { + 'type': 'array', + 'items': {'name': 'string', 'version': 'string'}, + }, + # Printer identifiers reported by the GE-Enforce side, which + # runs Get-CimInstance Win32_Printer and marks the default with + # the Default flag. An identifier is the printer's windows name, + # share name, hostname, or port/IP; the collector resolves it + # flexibly to a printer asset. Both optional. Presence of either + # key drives the PC->printer relationship sync (and stale-link + # archive); absence leaves existing printer links untouched. + 'defaultprinter': { + 'type': 'string', + 'description': ('Default printer identifier (Win32_Printer ' + 'with Default=true): windows name, share ' + 'name, hostname, or port IP.'), + }, + 'printers': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': ('All installed network printer identifiers ' + '(Win32_Printer): windows name / share / ' + 'hostname / IP. Unresolved -> warning.'), + }, + }, + } + + def apply_collector_payload(self, payload: Dict) -> Dict: + """Idempotent upsert of a PC from a collector payload (by hostname).""" + from datetime import datetime, timezone + from shopdb.api import ( + Asset, Application, Communication, CommunicationType, + Vendor, Model, OperatingSystem, + ) + from .pctypemap import pctype_mapping + + warnings = [] + hostname = (payload.get('hostname') or '').strip() + if not hostname: + raise ValueError('hostname is required') + + # Machine number is the business identifier (Asset.assetnumber). Skip + # the imaging-time placeholder '9999' and fall back to hostname. + machinenumber = (payload.get('machinenumber') or '').strip() + if machinenumber in ('', '9999'): + machinenumber = None + + 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=machinenumber or 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' + elif machinenumber and comp.asset: + comp.asset.assetnumber = machinenumber + + comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None) + if payload.get('lastboottime'): + try: + comp.lastboottime = datetime.fromisoformat( + payload['lastboottime'].replace('Z', '+00:00')) + except (ValueError, AttributeError): + warnings.append('lastboottime not parseable') + loggedinuser = payload.get('loggedinuser') or payload.get('currentuser') + if loggedinuser: + comp.loggedinuser = loggedinuser + if payload.get('serialnumber') and comp.asset: + comp.asset.serialnumber = payload['serialnumber'] + + # pc-type -> ComputerType via the configurable settings mapping. + pctype = (payload.get('pctype') or '').strip() + if pctype: + from .models import ComputerType + mapped = pctype_mapping().get(pctype) + if not mapped: + warnings.append(f'no ComputerType mapping for pctype: {pctype}') + else: + ctype = ComputerType.query.filter_by(computertype=mapped).first() + if ctype: + comp.computertypeid = ctype.computertypeid + else: + warnings.append(f'mapped ComputerType not found: {mapped}') + + # Vendor / model are free vocab - create if missing. + vendorname = (payload.get('vendorname') or '').strip() + vendor = None + if vendorname: + vendor = Vendor.query.filter(Vendor.vendor.ilike(vendorname)).first() + if not vendor: + vendor = Vendor(vendor=vendorname) + db.session.add(vendor) + db.session.flush() + comp.vendorid = vendor.vendorid + + modelnumber = (payload.get('modelnumber') or '').strip() + if modelnumber: + model_query = Model.query.filter(Model.modelnumber.ilike(modelnumber)) + if vendor: + model_query = model_query.filter(Model.vendorid == vendor.vendorid) + model = model_query.first() + if not model: + model = Model(modelnumber=modelnumber, + vendorid=vendor.vendorid if vendor else None) + db.session.add(model) + db.session.flush() + comp.modelnumberid = model.modelnumberid + + # OS is a controlled vocab - look up only, warn if unknown. + osname = (payload.get('osname') or '').strip() + if osname: + os_row = OperatingSystem.query.filter( + OperatingSystem.osname.ilike(osname)).first() + if os_row: + comp.osid = os_row.osid + else: + warnings.append(f'unknown operating system: {osname}') + + if payload.get('pcsubtype'): + warnings.append('pcsubtype received but not stored (no model field)') + + 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)) + + # Printer relationship sync (only when the payload carried printer data). + printerlinks = self._sync_printer_links(comp.asset, payload, warnings) + + # Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace) + # get an attached MeasuringTool asset auto-created and linked. + measuringtoollinks = self._sync_measuringtool_link( + comp.asset, pctype, hostname, warnings) + + db.session.commit() + return { + 'action': action, + 'assetid': comp.assetid, + 'identityvalue': hostname, + 'warnings': warnings, + 'extra': { + 'printerlinks': printerlinks, + 'printerlinkcount': len(printerlinks), + 'measuringtoollinks': measuringtoollinks, + 'measuringtoollinkcount': len(measuringtoollinks), + }, + } + + # -- printer relationship sync ----------------------------------------- + + def _sync_printer_links(self, pcasset, payload, warnings): + """Idempotently sync PC->printer relationships from collector printer data. + + Default printer -> 'defaultprinter' (directional). Other reported + printers -> 'connectedto' (symmetric). Resolves each identifier to a + printer asset by windows name / share / hostname / asset number / name + or a communications IP. Unresolved identifiers add a warning and never + fail the push. + + Stale-link archive: on each push, collector-tagged links (label == + PRINTER_LINK_ORIGIN) whose (target, type) pair is not in the reported + desired set are set inactive. Only tagged rows are touched, so manual + links survive. Runs only when the payload carried a printer key + ('defaultprinter' or 'printers'); a PC that reports without printer data + keeps its existing links. Returns the desired-link list (created + kept). + """ + from shopdb.api import ( + AssetRelationship, RelationshipType, Asset, Communication) + + has_default = 'defaultprinter' in payload + has_list = 'printers' in payload + if not has_default and not has_list: + return [] + + try: + from plugins.printers.models import Printer + except ImportError: + warnings.append('printers plugin unavailable; printer links skipped') + return [] + + dp_type = RelationshipType.query.filter_by( + relationshiptype='defaultprinter').first() + ct_type = RelationshipType.query.filter_by( + relationshiptype='connectedto').first() + if not dp_type or not ct_type: + warnings.append('printer relationship types missing; ' + 'run flask seed reference-data') + return [] + + def resolve(identifier): + # first match wins: printer text identity, then a printer IP. + ident = (identifier or '').strip() + if not ident: + return None + printer = ( + Printer.query.join(Asset, Asset.assetid == Printer.assetid) + .filter(Asset.isactive == True) + .filter(db.or_( + Printer.windowsname.ilike(ident), + Printer.hostname.ilike(ident), + Printer.sharename.ilike(ident), + Asset.assetnumber.ilike(ident), + Asset.name.ilike(ident), + )).first()) + if printer: + return printer.asset + comm = ( + db.session.query(Communication) + .join(Printer, Printer.assetid == Communication.assetid) + .filter(Communication.ipaddress == ident) + .first()) + if comm: + return db.session.get(Asset, comm.assetid) + return None + + pcid = pcasset.assetid + desired = set() # (targetassetid, relationshiptypeid) to keep + printerlinks = [] + + default_id = None + default_ident = (payload.get('defaultprinter') or '').strip() + if default_ident: + target = resolve(default_ident) + if target: + default_id = target.assetid + desired.add((default_id, dp_type.relationshiptypeid)) + self._sync_one(pcid, default_id, dp_type) + printerlinks.append({'assetid': default_id, + 'relationshiptype': 'defaultprinter'}) + else: + warnings.append(f'unresolved default printer: {default_ident}') + + for ident in payload.get('printers') or []: + target = resolve(ident) + if not target: + warnings.append(f'unresolved printer: {ident}') + continue + if target.assetid == default_id: + continue # already the default link + desired.add((target.assetid, ct_type.relationshiptypeid)) + self._sync_one(pcid, target.assetid, ct_type) + printerlinks.append({'assetid': target.assetid, + 'relationshiptype': 'connectedto'}) + + # Archive collector-tagged links no longer reported (manual links, with + # a NULL/other label, are never matched here). + collector_rows = AssetRelationship.query.filter( + AssetRelationship.sourceassetid == pcid, + AssetRelationship.relationshiptypeid.in_( + [dp_type.relationshiptypeid, ct_type.relationshiptypeid]), + AssetRelationship.isactive == True, + AssetRelationship.label == PRINTER_LINK_ORIGIN, + ).all() + for rel in collector_rows: + if (rel.targetassetid, rel.relationshiptypeid) not in desired: + rel.isactive = False + + return printerlinks + + def _sync_one(self, pcid, printerid, reltype): + """Reactivate or create one collector PC->printer link (idempotent).""" + from shopdb.api import AssetRelationship + existing = AssetRelationship.query.filter_by( + sourceassetid=pcid, targetassetid=printerid, + relationshiptypeid=reltype.relationshiptypeid).first() + if existing: + # do not re-stamp label: a pre-existing manual row stays manual. + if not existing.isactive: + existing.isactive = True + return existing + rel = AssetRelationship( + sourceassetid=pcid, targetassetid=printerid, + relationshiptypeid=reltype.relationshiptypeid, + label=PRINTER_LINK_ORIGIN) + db.session.add(rel) + return rel + + # -- measuring-tool sync ----------------------------------------------- + + def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings): + """Auto-create + link the MeasuringTool a metrology PC drives. + + A CMM / Keyence / Genspect / wax-and-trace imaging pc-type means the + shopfloor PC controls an attached measuring instrument. This creates + that instrument once as a MeasuringTool asset and a directional + PC->tool 'controls' relationship, tagged MEASURINGTOOL_LINK_ORIGIN so + it is idempotent and self-archiving. The PC's own ComputerType is left + alone (it stays a shopfloor PC). A non-metrology pc-type archives any + collector-created tool link (e.g. a PC re-imaged to another type) but + never deletes the tool asset, which may carry calibration history. + Returns the desired-link list. + """ + from shopdb.api import AssetRelationship, RelationshipType, Asset + from .pctypemap import metrology_tool_for + + tool_spec = metrology_tool_for(pctype) + controls = RelationshipType.query.filter_by( + relationshiptype='controls').first() + if not controls: + # No 'controls' type => no tool links can exist. Only a problem for a + # metrology PC that needs one; stay quiet for ordinary PCs. + if tool_spec: + warnings.append("'controls' relationship type missing; " + 'run flask seed reference-data') + return [] + + # Collector-created tool links already on this PC (active or archived). + existing = AssetRelationship.query.filter( + AssetRelationship.sourceassetid == pcasset.assetid, + AssetRelationship.relationshiptypeid == controls.relationshiptypeid, + AssetRelationship.label == MEASURINGTOOL_LINK_ORIGIN, + ).all() + + if not tool_spec: + # Not a metrology PC: archive any collector-created tool link. + for rel in existing: + if rel.isactive: + rel.isactive = False + return [] + + try: + from plugins.measuringtools.models import MeasuringTool + except ImportError: + warnings.append('measuringtools plugin unavailable; ' + 'tool link skipped') + return [] + + typename, typedescription = tool_spec + tooltype = self._ensure_measuringtool_type(typename, typedescription) + + # Reuse any prior collector link (reactivate + retype) before creating, + # so a re-metrology PC never duplicates the tool asset. + reuse = next((rel for rel in existing if rel.isactive), None) \ + or (existing[0] if existing else None) + if reuse: + reuse.isactive = True + toolasset = db.session.get(Asset, reuse.targetassetid) + if toolasset and toolasset.measuringtool and tooltype: + toolasset.measuringtool.measuringtooltypeid = \ + tooltype.measuringtooltypeid + targetid = reuse.targetassetid + else: + mt_assettype = AssetType.query.filter_by( + assettype='measuring_tool').first() + if not mt_assettype: + warnings.append('measuring_tool asset type missing; ' + 'tool link skipped') + return [] + suffix = (pctype or '').split('-')[-1].upper() + baseasset = pcasset.assetnumber or hostname + toolasset = Asset( + assetnumber=f'{baseasset}-{suffix}', + name=f'{typename} ({hostname})', + assettypeid=mt_assettype.assettypeid, + statusid=1) + db.session.add(toolasset) + db.session.flush() + db.session.add(MeasuringTool( + assetid=toolasset.assetid, + measuringtooltypeid=tooltype.measuringtooltypeid + if tooltype else None)) + db.session.add(AssetRelationship( + sourceassetid=pcasset.assetid, + targetassetid=toolasset.assetid, + relationshiptypeid=controls.relationshiptypeid, + label=MEASURINGTOOL_LINK_ORIGIN)) + targetid = toolasset.assetid + + # Only one tool link is desired; archive any other collector rows. + for rel in existing: + if rel is not reuse and rel.isactive: + rel.isactive = False + + return [{'assetid': targetid, 'relationshiptype': 'controls', + 'measuringtooltype': typename}] + + def _ensure_measuringtool_type(self, name, description): + """Find or create a MeasuringToolType (metrology types are not in the + measuringtools starter seed).""" + from plugins.measuringtools.models import MeasuringToolType + tooltype = MeasuringToolType.query.filter_by(name=name).first() + if not tooltype: + tooltype = MeasuringToolType(name=name, description=description) + db.session.add(tooltype) + db.session.flush() + return tooltype + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_computer_types() + from .pctypemap import seed_pctype_settings + seed_pctype_settings() + db.session.commit() + 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 (by the Shopfloor computer type) + sf = ComputerType.query.filter_by(computertype='Shopfloor').first() + shopfloor = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True, + Computer.computertypeid == (sf.computertypeid if sf else -1) + ).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, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('computers.view', 'View computers', 'computers'), + ('computers.create', 'Create computers', 'computers'), + ('computers.edit', 'Edit computers', 'computers'), + ('computers.delete', 'Delete computers', 'computers'), + ] diff --git a/plugins/employees/README.md b/plugins/employees/README.md new file mode 100644 index 00000000..8912c9e8 --- /dev/null +++ b/plugins/employees/README.md @@ -0,0 +1,133 @@ +# Employees plugin - directory database contract + +Read-only lookups against a **separate** employee/HR directory database. Powers: + +- Employee search (add people to a notification, browse the directory) +- Single + batch SSO lookup +- Recognition notifications (name + photo on the shopfloor / lobby displays) + +The plugin never writes to this database. Use a read-only account. + +> **This is the integration most likely to differ per site.** HR / directory +> systems vary widely, so expect to map a site's schema to the contract below - +> the `CREATE VIEW` recipe at the end is the normal way to do it. + +## Connection + +Credentials resolve **settings-first, then environment**, except the password +which is env-only (never stored in the app database). + +| Field | Setting key (editable in the setup wizard) | Env var (fallback) | +| -------- | ------------------------------------------ | ---------------------- | +| Host | `employee_db_host` | `EMPLOYEE_DB_HOST` | +| Database | `employee_db_name` | `EMPLOYEE_DB_NAME` | +| User | `employee_db_user` | `EMPLOYEE_DB_USER` | +| Password | (not stored) | `EMPLOYEE_DB_PASSWORD` | + +Set the password in `.env`; the setup wizard's Features step shows the exact +line to paste. Engine is MySQL/MariaDB (pymysql). + +## Required schema + +The plugin runs these queries verbatim, so a site's database must expose a +table (or **view** - see below) named `employees` with these columns: + +| Column | Type | Notes | +| ------------ | ------------------- | ------------------------------------------------- | +| `SSO` | INT | Unique person id. Lookups require it to be numeric | +| `First_Name` | VARCHAR | Displayed as the given name | +| `Last_Name` | VARCHAR | Displayed as the surname; sort key | +| `Team` | VARCHAR | Team / group label | +| `Role` | VARCHAR | Job title / role | +| `Picture` | VARCHAR | **Image filename** (see Photos) | + +Queries actually executed: + +```sql +-- search +SELECT SSO, First_Name, Last_Name, Team, Role, Picture +FROM employees +WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s +ORDER BY Last_Name, First_Name LIMIT %s; + +-- single / batch +SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO = %s; +SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN (...); +``` + +Rows are returned to the API **with these exact column names**. The frontend +(EmployeeSearch, NotificationForm, EmployeeDetail, ShopfloorDashboard) reads +`SSO`, `First_Name`, `Last_Name`, `Team`, `Role`, `Picture` as-is - do not +rename them in the response. + +## Photos + +`Picture` holds an **image filename** (e.g. `123456.jpg`), not a path or blob. +The app renders it as `/static/employees/`, so the image files must +live in the app's `static/employees/` directory. Leave `Picture` empty/NULL for +people with no photo; the UI falls back to initials. + +## Two ways to provide the directory + +**Option A - map an existing HR/directory database** (see the view recipe +below). Use this when the site already has a system of record for people. + +**Option B - self-hosted directory (managed in-app)** for sites with no HR +database. Set `employee_directory_mode` to `selfhosted` and manage people under +**Settings > Employee Directory** (add / edit / delete + CSV import) - no SQL +needed. The app owns a `directoryemployees` table (migration 7d16); the lookup +APIs read it automatically in this mode. + +If you would rather load it with SQL, the canonical table is equivalent to: + +```sql +CREATE DATABASE shopdb_directory CHARACTER SET utf8mb4; +USE shopdb_directory; + +CREATE TABLE employees ( + SSO INT NOT NULL PRIMARY KEY, + First_Name VARCHAR(100) NOT NULL, + Last_Name VARCHAR(100) NOT NULL, + Team VARCHAR(100) NULL, + Role VARCHAR(100) NULL, + Picture VARCHAR(255) NULL +); + +-- add people (or bulk-load from CSV with LOAD DATA INFILE) +INSERT INTO employees (SSO, First_Name, Last_Name, Team, Role, Picture) +VALUES (123456, 'Jane', 'Doe', 'Inspection', 'Quality Tech', '123456.jpg'); +``` + +Put photo files (named as in `Picture`) under the app's `static/employees/`. +In self-hosted mode the `directoryemployees` table lives in the main app DB, so +no separate database is required. + +## Adapting a different site schema (recommended: a view) + +Sites whose HR/directory database uses different table or column names should +**not** be forced to rename anything. Instead, create a read-only **view** +named `employees` that maps local columns to the names above: + +```sql +CREATE VIEW employees AS +SELECT + person_id AS SSO, + given_name AS First_Name, + surname AS Last_Name, + department AS Team, + job_title AS Role, + photo_filename AS Picture +FROM hr_people +WHERE active = 1; +``` + +Grant the app's read-only user `SELECT` on the view. No app code changes - +point `employee_db_*` at that database and the plugin works. + +Notes: +- `SSO` must be numeric (single/batch lookup validate `isdigit()`). +- Column-name case follows your database's identifier casing; match the names + above exactly on case-sensitive platforms. +- If the directory is unreachable or the `employees` object is missing, lookups + return a 500 and the rest of the app keeps working (the feature degrades, it + does not crash the app). diff --git a/plugins/employees/__init__.py b/plugins/employees/__init__.py new file mode 100644 index 00000000..b2ff6752 --- /dev/null +++ b/plugins/employees/__init__.py @@ -0,0 +1,5 @@ +"""Employees plugin package.""" + +from .plugin import EmployeesPlugin + +__all__ = ['EmployeesPlugin'] diff --git a/plugins/employees/api/__init__.py b/plugins/employees/api/__init__.py new file mode 100644 index 00000000..50aefcc3 --- /dev/null +++ b/plugins/employees/api/__init__.py @@ -0,0 +1,5 @@ +"""Employees plugin API.""" + +from .routes import employees_bp + +__all__ = ['employees_bp'] diff --git a/plugins/employees/api/routes.py b/plugins/employees/api/routes.py new file mode 100644 index 00000000..0fb8f024 --- /dev/null +++ b/plugins/employees/api/routes.py @@ -0,0 +1,504 @@ +"""Employee lookup API endpoints. + +These read from the separate employee directory DB (employee_connection is core +infrastructure exposed via shopdb.api, shared with search + notifications). The +endpoints are intentionally reachable by the unauthenticated shopfloor kiosk +displays (recognition wall), so they are not JWT-gated; keep them read-only and +never return more than the directory fields below. +""" + +import csv +import glob +import io +import logging +import os + +from flask import Blueprint, request, current_app, send_from_directory +from flask_jwt_extended import jwt_required +from werkzeug.utils import secure_filename + +from shopdb.api import ( + db, + success_response, + error_response, + ErrorCodes, + employee_connection, + require_role, +) +from shopdb.api import Setting + +from ..models import DirectoryEmployee + +logger = logging.getLogger(__name__) + +employees_bp = Blueprint('employees', __name__) + +# Columns safe to expose to the directory/recognition UI +_FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture' + +# Uploaded self-hosted employee photos live in the instance dir and are served +# publicly (kiosk recognition/recertification cards read them without auth). +EMPLOYEE_PHOTO_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'} + +# URL prefix a served upload resolves to (self-hosted mode). +EMPLOYEE_PHOTO_URL_PREFIX = '/api/employees/photo/' + +# URL prefix external HR relative picture paths resolve under. The HR employees +# table stores Picture as a relative path (e.g. 'Support/210009518.png') that +# the site serves from /static/employees/; this matches the shopfloor feed. +EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/' + + +def _employeephoto_dir(): + return os.path.join(current_app.instance_path, 'employeephotos') + + +def _selfhosted(): + """True when the directory is the app-owned table, not an external HR DB.""" + row = Setting.query.filter_by(key='employee_directory_mode').first() + return (row.value if row and row.value else 'external').lower() == 'selfhosted' + + +def _require_selfhosted(): + """Guard for management endpoints - only valid in self-hosted mode.""" + if not _selfhosted(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Directory is in external mode; manage people in the source HR database.', + http_code=400) + return None + + +def _require_selfhosted_photo(): + """Guard for photo write endpoints - 409 when the directory is external. + + In external mode the photo is owned by the HR database (read-only + pass-through), so upload/delete cannot apply here.""" + if not _selfhosted(): + return error_response( + ErrorCodes.CONFLICT, + 'Employee directory is external; photos are supplied by the HR ' + 'database and cannot be uploaded or deleted here.', + http_code=409) + return None + + +def _external_photo_url(picture): + """Turn an external HR Picture value into a usable URL, or None. + + Absolute URLs and already-rooted paths pass through untouched (future + full-URL HR feeds); a bare relative path is served under the static prefix + (current WJ convention, e.g. 'Support/210009518.png').""" + if not picture: + return None + text = str(picture).strip() + if not text: + return None + if text.startswith(('http://', 'https://', '/')): + return text + return EMPLOYEE_PHOTO_STATIC_PREFIX + text + + +def _hr_picture(sso): + """Raw Picture value for an SSO from the external HR directory. None on miss.""" + try: + conn = employee_connection() + with conn.cursor() as cur: + cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) + row = cur.fetchone() + conn.close() + return row.get('Picture') if row else None + except Exception: + return None + + +def resolve_employee_photo_url(sso, external_picture=None): + """Single resolver both consumers share: the display photo URL for an SSO. + + Self-hosted: the served upload URL when the employee has an uploaded photo, + else None (the legacy Picture text field does not drive the photo here). + External: the HR-supplied Picture resolved to a URL (pass external_picture to + avoid a re-query), else None. Returns None on any miss or bad SSO.""" + if sso is None or not str(sso).isdigit(): + return None + if _selfhosted(): + emp = db.session.get(DirectoryEmployee, int(sso)) + if emp and emp.photofilename: + return EMPLOYEE_PHOTO_URL_PREFIX + emp.photofilename + return None + picture = external_picture if external_picture is not None else _hr_picture(sso) + return _external_photo_url(picture) + + +def _with_photo_url(employee): + """Add the resolved photourl to an employee dict (self-hosted or external).""" + employee['photourl'] = resolve_employee_photo_url( + employee.get('SSO'), employee.get('Picture')) + return employee + + +@employees_bp.route('/search', methods=['GET']) +def search_employees(): + """ + Search employees by name. + + Query parameters: + - q: Search query (searches first and last name) + - limit: Max results (default 10) + """ + query = request.args.get('q', '').strip() + limit = min(int(request.args.get('limit', 10)), 50) + + if len(query) < 2: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Search query must be at least 2 characters' + ) + + if _selfhosted(): + term = f'%{query}%' + rows = (DirectoryEmployee.query + .filter(db.or_(DirectoryEmployee.firstname.ilike(term), + DirectoryEmployee.lastname.ilike(term), + db.cast(DirectoryEmployee.sso, db.String).ilike(term))) + .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname) + .limit(limit).all()) + return success_response([_with_photo_url(e.to_dict()) for e in rows]) + + try: + conn = employee_connection() + with conn.cursor() as cur: + cur.execute(f''' + SELECT {_FIELDS} + FROM employees + WHERE First_Name LIKE %s + OR Last_Name LIKE %s + OR CAST(SSO AS CHAR) LIKE %s + ORDER BY Last_Name, First_Name + LIMIT %s + ''', (f'%{query}%', f'%{query}%', f'%{query}%', limit)) + employees = cur.fetchall() + conn.close() + return success_response([_with_photo_url(e) for e in employees]) + except Exception: + logger.exception('Employee search failed') + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Employee lookup failed', + http_code=500 + ) + + +@employees_bp.route('/lookup/', methods=['GET']) +def lookup_employee(sso): + """Look up a single employee by SSO.""" + if not sso.isdigit(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'SSO must be numeric' + ) + + if _selfhosted(): + emp = db.session.get(DirectoryEmployee, int(sso)) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, + f'Employee with SSO {sso} not found', http_code=404) + return success_response(_with_photo_url(emp.to_dict())) + + try: + conn = employee_connection() + with conn.cursor() as cur: + cur.execute( + f'SELECT {_FIELDS} FROM employees WHERE SSO = %s', + (int(sso),) + ) + employee = cur.fetchone() + conn.close() + + if not employee: + return error_response( + ErrorCodes.NOT_FOUND, + f'Employee with SSO {sso} not found', + http_code=404 + ) + + return success_response(_with_photo_url(employee)) + except Exception: + logger.exception('Employee lookup failed for SSO %s', sso) + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Employee lookup failed', + http_code=500 + ) + + +@employees_bp.route('/lookup', methods=['GET']) +def lookup_employees(): + """ + Look up multiple employees by SSO list. + + Query parameters: + - sso: Comma-separated list of SSOs + """ + sso_list = request.args.get('sso', '') + ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()] + + if not ssos: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'At least one valid SSO is required' + ) + + if _selfhosted(): + rows = DirectoryEmployee.query.filter( + DirectoryEmployee.sso.in_([int(s) for s in ssos])).all() + employees = [_with_photo_url(e.to_dict()) for e in rows] + names = ', '.join(f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" + for e in employees) + return success_response({'employees': employees, 'names': names}) + + try: + conn = employee_connection() + with conn.cursor() as cur: + placeholders = ','.join(['%s'] * len(ssos)) + cur.execute( + f'SELECT {_FIELDS} FROM employees WHERE SSO IN ({placeholders})', + [int(s) for s in ssos] + ) + employees = cur.fetchall() + conn.close() + + employees = [_with_photo_url(e) for e in employees] + names = ', '.join( + f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" + for e in employees + ) + + return success_response({ + 'employees': employees, + 'names': names + }) + except Exception: + logger.exception('Employee multi-lookup failed') + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Employee lookup failed', + http_code=500 + ) + + +# ============================================================================= +# Self-hosted directory management (only when directory_mode=selfhosted) +# ============================================================================= + +@employees_bp.route('/directory', methods=['GET']) +@jwt_required(optional=True) +def list_directory(): + """Full self-hosted directory (for the management page).""" + guard = _require_selfhosted() + if guard: + return guard + rows = (DirectoryEmployee.query + .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).all()) + return success_response([_with_photo_url(e.to_dict()) for e in rows]) + + +def _employee_from_payload(data): + """Build kwargs from a payload accepting either external-style (SSO, + First_Name...) or plain (sso, firstname...) keys.""" + def pick(*keys): + for key in keys: + if data.get(key) not in (None, ''): + return data.get(key) + return None + return { + 'sso': pick('sso', 'SSO'), + 'firstname': pick('firstname', 'First_Name'), + 'lastname': pick('lastname', 'Last_Name'), + 'team': pick('team', 'Team'), + 'role': pick('role', 'Role'), + 'picture': pick('picture', 'Picture'), + } + + +@employees_bp.route('/directory', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_directory_employee(): + guard = _require_selfhosted() + if guard: + return guard + fields = _employee_from_payload(request.get_json() or {}) + if not (fields['sso'] and fields['firstname'] and fields['lastname']): + return error_response(ErrorCodes.VALIDATION_ERROR, 'sso, firstname and lastname are required') + try: + sso = int(fields['sso']) + except (ValueError, TypeError): + return error_response(ErrorCodes.VALIDATION_ERROR, 'sso must be numeric') + if db.session.get(DirectoryEmployee, sso): + return error_response(ErrorCodes.CONFLICT, f'SSO {sso} already exists', http_code=409) + emp = DirectoryEmployee(sso=sso, firstname=fields['firstname'], lastname=fields['lastname'], + team=fields['team'], role=fields['role'], picture=fields['picture']) + db.session.add(emp) + db.session.commit() + return success_response(emp.to_dict(), message='Employee added', http_code=201) + + +@employees_bp.route('/directory/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_directory_employee(sso): + guard = _require_selfhosted() + if guard: + return guard + emp = db.session.get(DirectoryEmployee, sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + fields = _employee_from_payload(request.get_json() or {}) + if fields['firstname']: + emp.firstname = fields['firstname'] + if fields['lastname']: + emp.lastname = fields['lastname'] + for key in ('team', 'role', 'picture'): + if key in (request.get_json() or {}) or fields[key] is not None: + setattr(emp, key, fields[key]) + db.session.commit() + return success_response(emp.to_dict(), message='Employee updated') + + +@employees_bp.route('/directory/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_directory_employee(sso): + guard = _require_selfhosted() + if guard: + return guard + emp = db.session.get(DirectoryEmployee, sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + db.session.delete(emp) + db.session.commit() + return success_response(message='Employee removed') + + +@employees_bp.route('/directory/import', methods=['POST']) +@jwt_required() +@require_role('admin') +def import_directory(): + """Bulk upsert from CSV. Accepts headers SSO,First_Name,Last_Name,Team,Role, + Picture (case-insensitive; sso/firstname/... also accepted).""" + guard = _require_selfhosted() + if guard: + return guard + text = '' + if 'file' in request.files: + text = request.files['file'].read().decode('utf-8-sig', errors='replace') + else: + data = request.get_json(silent=True) or {} + text = data.get('csv', '') + if not text.strip(): + return error_response(ErrorCodes.VALIDATION_ERROR, 'No CSV provided') + + reader = csv.DictReader(io.StringIO(text)) + # Normalize headers to lower for tolerant matching. + added = updated = skipped = 0 + for raw in reader: + row = {(k or '').strip().lower(): (v or '').strip() for k, v in raw.items()} + sso_raw = row.get('sso') or row.get('sso ') + first = row.get('first_name') or row.get('firstname') + last = row.get('last_name') or row.get('lastname') + if not (sso_raw and sso_raw.isdigit() and first and last): + skipped += 1 + continue + sso = int(sso_raw) + team = row.get('team') or None + role = row.get('role') or None + picture = row.get('picture') or None + emp = db.session.get(DirectoryEmployee, sso) + if emp: + emp.firstname, emp.lastname, emp.team, emp.role, emp.picture = first, last, team, role, picture + updated += 1 + else: + db.session.add(DirectoryEmployee(sso=sso, firstname=first, lastname=last, + team=team, role=role, picture=picture)) + added += 1 + db.session.commit() + return success_response({'added': added, 'updated': updated, 'skipped': skipped}, + message=f'Import done: {added} added, {updated} updated, {skipped} skipped.') + + +# ============================================================================= +# Self-hosted employee photos (upload/replace/delete + public serve) +# ============================================================================= + +@employees_bp.route('//photo', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_employee_photo(sso): + """Upload (or replace) the photo for a self-hosted directory employee. + + multipart/form-data: file=. Saves to the instance employeephotos dir + as photo- (one photo per person) and points photofilename at it. + Re-upload replaces the old file even when the extension changes. External + mode is a 409 (photo is owned by the HR database).""" + guard = _require_selfhosted_photo() + if guard: + return guard + + emp = db.session.get(DirectoryEmployee, sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in EMPLOYEE_PHOTO_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + photodir = _employeephoto_dir() + os.makedirs(photodir, exist_ok=True) + + # Wipe any prior photo-.* so a new extension does not orphan the old one. + for old in glob.glob(os.path.join(photodir, secure_filename(f'photo-{sso}') + '.*')): + os.remove(old) + + filename = secure_filename(f'photo-{sso}{ext}') + upload.save(os.path.join(photodir, filename)) + + emp.photofilename = filename + db.session.commit() + + return success_response(_with_photo_url(emp.to_dict()), message='Employee photo uploaded') + + +@employees_bp.route('/photo/', methods=['GET']) +def serve_employee_photo(filename): + """Serve an uploaded employee photo (public - kiosk cards read it).""" + return send_from_directory(_employeephoto_dir(), filename) + + +@employees_bp.route('//photo', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_employee_photo(sso): + """Clear an employee photo and delete the uploaded file. External mode 409s.""" + guard = _require_selfhosted_photo() + if guard: + return guard + + emp = db.session.get(DirectoryEmployee, sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + + if emp.photofilename: + # secure_filename strips any traversal; the file lives in our dir only. + path = os.path.join(_employeephoto_dir(), secure_filename(emp.photofilename)) + if os.path.exists(path): + os.remove(path) + + emp.photofilename = None + db.session.commit() + + return success_response(_with_photo_url(emp.to_dict()), message='Employee photo removed') diff --git a/plugins/employees/manifest.json b/plugins/employees/manifest.json new file mode 100644 index 00000000..0460d2bc --- /dev/null +++ b/plugins/employees/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "employees", + "version": "1.0.0", + "description": "Read-only employee directory lookup (separate HR database)", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/employees", + "provides": { + "features": [ + "employee_directory" + ] + }, + "default_enabled": false +} \ No newline at end of file diff --git a/plugins/employees/migrations/env.py b/plugins/employees/migrations/env.py new file mode 100644 index 00000000..548c662c --- /dev/null +++ b/plugins/employees/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the employees plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_employees. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'employees' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/employees/migrations/script.py.mako b/plugins/employees/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/employees/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/employees/migrations/versions/0001_employees_anchor.py b/plugins/employees/migrations/versions/0001_employees_anchor.py new file mode 100644 index 00000000..bfd6ebcd --- /dev/null +++ b/plugins/employees/migrations/versions/0001_employees_anchor.py @@ -0,0 +1,28 @@ +"""employees plugin anchor (ownership cutover). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_employees. From this +anchor forward, new employees schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'employees0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/employees/migrations/versions/0002_employee_photofilename.py b/plugins/employees/migrations/versions/0002_employee_photofilename.py new file mode 100644 index 00000000..6aad6557 --- /dev/null +++ b/plugins/employees/migrations/versions/0002_employee_photofilename.py @@ -0,0 +1,40 @@ +"""Add photofilename to directoryemployees (self-hosted employee photos). + +The core chain (7d16_directoryemployees) created directoryemployees WITHOUT a +photofilename column. This plugin revision adds it, so BOTH fresh installs (core +chain builds the table, then this adds the column) and existing installs get it. +Guarded/idempotent: skips when the table is absent (plugin disabled) or the +column already exists (e.g. a test DB built by db.create_all() from the model). + +Revision ID: employees0002photo +Revises: employees0001anchor +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'employees0002photo' +down_revision = 'employees0001anchor' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'directoryemployees' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('directoryemployees')} + if 'photofilename' not in cols: + op.add_column('directoryemployees', + sa.Column('photofilename', sa.String(length=255), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'directoryemployees' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('directoryemployees')} + if 'photofilename' in cols: + op.drop_column('directoryemployees', 'photofilename') diff --git a/plugins/employees/models/__init__.py b/plugins/employees/models/__init__.py new file mode 100644 index 00000000..25343b4d --- /dev/null +++ b/plugins/employees/models/__init__.py @@ -0,0 +1,5 @@ +"""Employees plugin models.""" + +from .directory_employee import DirectoryEmployee + +__all__ = ['DirectoryEmployee'] diff --git a/plugins/employees/models/directory_employee.py b/plugins/employees/models/directory_employee.py new file mode 100644 index 00000000..865d96fc --- /dev/null +++ b/plugins/employees/models/directory_employee.py @@ -0,0 +1,43 @@ +"""Self-hosted employee directory. + +For sites with no external HR database. When employee_directory_mode is +'selfhosted', the employee lookup APIs read this app-owned table instead of the +external directory, and the directory is managed in-app (CRUD + CSV import). + +to_dict emits the same keys the external contract returns (SSO, First_Name, +Last_Name, Team, Role, Picture) so the frontend and both modes share one shape. + +photofilename holds the basename of an uploaded photo (photo-) served +from instance/employeephotos/. It is distinct from the legacy Picture text +field: in self-hosted mode the displayed photo comes from uploads (photofilename) +via the shared resolver, not from Picture. +""" + +from shopdb.api import db + + +class DirectoryEmployee(db.Model): + __tablename__ = 'directoryemployees' + + sso = db.Column(db.Integer, primary_key=True, autoincrement=False) + firstname = db.Column(db.String(100), nullable=False) + lastname = db.Column(db.String(100), nullable=False) + team = db.Column(db.String(100)) + role = db.Column(db.String(100)) + picture = db.Column(db.String(255)) + # basename of an uploaded photo (photo-); None when no upload + photofilename = db.Column(db.String(255)) + + def to_dict(self): + # Keys match the external employees contract the frontend consumes. + # photofilename is extra (self-hosted upload); the resolved display URL + # is added as photourl by the API layer via resolve_employee_photo_url. + return { + 'SSO': self.sso, + 'First_Name': self.firstname, + 'Last_Name': self.lastname, + 'Team': self.team, + 'Role': self.role, + 'Picture': self.picture, + 'photofilename': self.photofilename, + } diff --git a/plugins/employees/plugin.py b/plugins/employees/plugin.py new file mode 100644 index 00000000..5f0a35bd --- /dev/null +++ b/plugins/employees/plugin.py @@ -0,0 +1,98 @@ +"""Employees plugin main class. + +Blueprint-only plugin: a read-only employee directory lookup over a separate HR +database. No model (the directory is an external DB) and no AssetType. The +connection helper (employee_connection) stays core infrastructure in shopdb.api, +shared with search and the notifications shopfloor feed. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta + +from .api import employees_bp +from .models import DirectoryEmployee + +logger = logging.getLogger(__name__) + + +class EmployeesPlugin(BasePlugin): + """Employees plugin - read-only HR directory lookup.""" + + 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', 'employees'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Read-only employee directory lookup' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/employees'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return employees_bp + + def get_models(self) -> List[Type]: + """Self-hosted directory table (used when directory_mode=selfhosted). + External mode reads a separate DB via employee_connection instead.""" + return [DirectoryEmployee] + + def get_provisioning_note(self) -> Optional[Dict]: + return { + 'mode_setting': 'employee_directory_mode', + 'tables': ['directoryemployees'], + 'note': ('Enabling this in self-hosted mode creates a ' + '"directoryemployees" table in the shopdb database (SSO, ' + 'First_Name, Last_Name, Team, Role, Picture). It is ' + 'referenced across the site: employee search, notification ' + 'recognition (name + photo on the shopfloor/lobby displays), ' + 'and USB check-in/out name resolution. Manage people under ' + 'Settings > Employee Directory, or point at an existing HR ' + 'database instead (external mode).'), + 'docs': 'plugins/employees/README.md', + } + + def get_config_schema(self) -> List[Dict]: + """Employee directory DB connection. Host/name/user are settings the + wizard can edit; the password stays in .env (emitted, not stored).""" + return [ + {'key': 'employee_db_host', 'label': 'Employee DB host', 'type': 'text', + 'secret': False, 'default': 'localhost', + 'help': 'This DB must expose an "employees" table or view with columns ' + 'SSO, First_Name, Last_Name, Team, Role, Picture. ' + 'See plugins/employees/README.md.'}, + {'key': 'employee_db_name', 'label': 'Employee DB name', 'type': 'text', + 'secret': False, 'default': 'wjf_employees'}, + {'key': 'employee_db_user', 'label': 'Employee DB user', 'type': 'text', + 'secret': False}, + {'key': 'employee_db_password', 'label': 'Employee DB password', 'type': 'password', + 'secret': True, 'envvar': 'EMPLOYEE_DB_PASSWORD', + 'help': 'Stored in .env, not the database. The wizard shows the line to paste.'}, + ] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Employees plugin initialized (v{self.meta.version})") diff --git a/plugins/geenforce/__init__.py b/plugins/geenforce/__init__.py new file mode 100644 index 00000000..8798ba98 --- /dev/null +++ b/plugins/geenforce/__init__.py @@ -0,0 +1,5 @@ +"""GE-Enforce manifest-store plugin package.""" + +from .plugin import GeEnforcePlugin + +__all__ = ['GeEnforcePlugin'] diff --git a/plugins/geenforce/api/__init__.py b/plugins/geenforce/api/__init__.py new file mode 100644 index 00000000..34c47e78 --- /dev/null +++ b/plugins/geenforce/api/__init__.py @@ -0,0 +1,5 @@ +"""GE-Enforce plugin API blueprint.""" + +from .routes import geenforce_bp + +__all__ = ['geenforce_bp'] diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py new file mode 100644 index 00000000..c93d34e7 --- /dev/null +++ b/plugins/geenforce/api/routes.py @@ -0,0 +1,712 @@ +"""GE-Enforce plugin API. + +Two audiences: +- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest. + Full CRUD + publish lands in P2; this is the P1/first-slice read surface. +- Client (service token, geenforce.fetch scope): GET /manifest serves the + CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the + collector's managed-token pattern (X-API-Key or Bearer PAT). +""" + +from functools import wraps + +from flask import Blueprint, request, Response +from flask_jwt_extended import jwt_required +from sqlalchemy.exc import IntegrityError + +from shopdb.api import ( + db, success_response, error_response, ErrorCodes, require_permission, + service_token_authorized, Setting, Application, +) + +SHAREROOT_SETTING = 'geenforce_share_root' + +from ..models import ( + ManifestScope, ManifestEntry, ManifestPublishedVersion, + ManifestEnforcementReport, ManifestPayload, ENTRY_TYPES, PHASES, +) +from ..serializer import scope_to_manifest, entry_to_dict +from ..importer import build_entry, populate_entry +from ..filters import ( + entry_applies, matches_pctype, matches_hostname, matches_machinenumber, + matches_cmmversion, +) +from .. import service + +geenforce_bp = Blueprint('geenforce', __name__) + +FETCH_SCOPE = 'geenforce.fetch' +REPORT_SCOPE = 'geenforce.report' + + +def _require_service_token(scope): + """Decorator factory: require a managed service token scoped for `scope`. + + Tokens are the only client auth path (the client kit provisions a + geenforce.fetch/report token). No env-key fallback: it was never wired into + config and an unscoped shared key is a needless backdoor. + """ + def wrapper(f): + @wraps(f) + def decorated(*args, **kwargs): + if service_token_authorized(scope): + return f(*args, **kwargs) + return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', + http_code=401) + return decorated + return wrapper + + +require_fetch_token = _require_service_token(FETCH_SCOPE) +require_report_token = _require_service_token(REPORT_SCOPE) + + +# -- client-facing endpoint --------------------------------------------------- + +@geenforce_bp.route('/manifest', methods=['GET']) +@require_fetch_token +def get_manifest(): + """Serve the current published manifest for a scope (fat-client: full scope). + + Query: pctype (=scopename, required), phase (default runtime). The engine + filters client-side, matching today, so subtype/hostname/machinenumber/ + cmmversion are accepted but not applied here. + """ + scopename = (request.args.get('pctype') or '').strip() + phase = (request.args.get('phase') or 'runtime').strip() + if not scopename: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'pctype is required', http_code=400) + + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + return error_response(ErrorCodes.NOT_FOUND, + f'No scope: {scopename}', http_code=404) + + published = scope.publishedversions.filter_by(iscurrent=True).first() + if not published: + return error_response(ErrorCodes.NOT_FOUND, + f'{scopename} has no published version', + http_code=404) + + etag = f'"{scope.scopeid}-v{published.versionnumber}"' + if request.headers.get('If-None-Match') == etag: + return Response(status=304, headers={'ETag': etag}) + return Response(published.manifestjson, mimetype='application/json', + headers={'ETag': etag, + 'X-Manifest-Version': str(published.versionnumber)}) + + +# -- client reporting (observed state) ---------------------------------------- + +@geenforce_bp.route('/report', methods=['POST']) +@require_report_token +def post_report(): + """Record one PC's enforcement cycle: applied version (did it receive the + update?) + per-entry self-heal outcomes (installed/skipped/failed).""" + payload = request.get_json(silent=True) or {} + if not (payload.get('hostname') or '').strip(): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname is required', http_code=400) + try: + report = service.record_enforcement_report(payload) + db.session.commit() + except ValueError as exc: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), + http_code=400) + return success_response({'reportid': report.reportid, + 'status': report.status}, message='recorded') + + +# -- admin read surface (P2 adds full CRUD + publish) ------------------------- + +@geenforce_bp.route('/scopes', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def list_scopes(): + """List imaging PC-type scopes with entry + published-version counts.""" + scopes = ManifestScope.query.order_by( + ManifestScope.phase, ManifestScope.scopename).all() + data = [] + for scope in scopes: + current = scope.publishedversions.filter_by(iscurrent=True).first() + data.append({ + 'scopeid': scope.scopeid, + 'scopename': scope.scopename, + 'phase': scope.phase, + 'manifestversion': scope.manifestversion, + 'computertypeid': scope.computertypeid, + 'measuringtooltypeid': scope.measuringtooltypeid, + 'iscommon': scope.iscommon, + 'entrycount': len(scope.entries), + 'publishedversion': current.versionnumber if current else None, + }) + return success_response(data) + + +@geenforce_bp.route('/scopes//preview', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def preview_scope(scopeid): + """Render the DRAFT manifest JSON a publish would freeze (review before ship).""" + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', + http_code=404) + return success_response({'scopename': scope.scopename, + 'manifest': scope_to_manifest(scope)}) + + +@geenforce_bp.route('/applications', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def list_applications(): + """The core Applications catalog (id + name) for the curated entry->app link + picker in the entry editor.""" + apps = Application.query.filter_by(isactive=True).order_by( + Application.appname).all() + return success_response([{'appid': a.appid, 'appname': a.appname} + for a in apps]) + + +# -- scope CRUD (geenforce.manage) -------------------------------------------- + +def _scope_summary(scope): + current = scope.publishedversions.filter_by(iscurrent=True).first() + return { + 'scopeid': scope.scopeid, + 'scopename': scope.scopename, + 'phase': scope.phase, + 'manifestversion': scope.manifestversion, + 'description': scope.description, + 'computertypeid': scope.computertypeid, + 'measuringtooltypeid': scope.measuringtooltypeid, + 'iscommon': scope.iscommon, + 'entrycount': len(scope.entries), + 'publishedversion': current.versionnumber if current else None, + } + + +def _entry_payload(entry): + """Manifest-entry dict with entryid + sortorder + the curated app link. + + appid/appname are shopdb metadata (not manifest fields), added alongside the + rendered manifest entry for the editor. + """ + data = {'entryid': entry.entryid, 'sortorder': entry.sortorder, + 'appid': entry.appid, 'appname': None} + if entry.appid: + app = db.session.get(Application, entry.appid) + data['appname'] = app.appname if app else None + data.update(entry_to_dict(entry)) + # Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor. + data['payloadsource'] = entry.payloadsource + data['payloadref'] = entry.payloadref + data['payloadsha256'] = entry.payloadsha256 + data['haspayload'] = (entry.payloadsource == 'inline' + and entry.payloadsha256 is not None) + return data + + +def _apply_app_link(entry, payload): + """Set the optional curated appid from the payload (shopdb metadata, not a + manifest field, so handled outside populate_entry). Ignores an unknown id.""" + if 'appid' not in payload: + return + appid = payload.get('appid') + if appid in (None, '', 0): + entry.appid = None + return + # Ignore a non-numeric id rather than 500 (matches the docstring contract). + try: + appid = int(appid) + except (ValueError, TypeError): + return + if db.session.get(Application, appid): + entry.appid = appid + + +@geenforce_bp.route('/scopes', methods=['POST']) +@jwt_required() +@require_permission('geenforce.manage') +def create_scope(): + payload = request.get_json(silent=True) or {} + scopename = (payload.get('scopename') or '').strip() + phase = (payload.get('phase') or 'runtime').strip() + if not scopename: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'scopename is required', http_code=400) + if phase not in PHASES: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'phase must be one of {PHASES}', http_code=400) + if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first(): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'scope already exists', http_code=400) + scope = ManifestScope( + scopename=scopename, phase=phase, + manifestversion=str(payload.get('manifestversion', '1.0')), + description=payload.get('description'), + computertypeid=payload.get('computertypeid'), + measuringtooltypeid=payload.get('measuringtooltypeid'), + iscommon=bool(payload.get('iscommon', scopename == 'common'))) + db.session.add(scope) + db.session.commit() + return success_response(_scope_summary(scope), http_code=201) + + +@geenforce_bp.route('/scopes/', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def get_scope(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + data = _scope_summary(scope) + data['entries'] = [_entry_payload(e) for e in scope.entries] + return success_response(data) + + +@geenforce_bp.route('/scopes/', methods=['PUT']) +@jwt_required() +@require_permission('geenforce.manage') +def update_scope(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + payload = request.get_json(silent=True) or {} + for field in ('description', 'computertypeid', 'measuringtooltypeid'): + if field in payload: + setattr(scope, field, payload[field]) + if 'manifestversion' in payload: + scope.manifestversion = str(payload['manifestversion']) + if 'iscommon' in payload: + scope.iscommon = bool(payload['iscommon']) + db.session.commit() + return success_response(_scope_summary(scope)) + + +@geenforce_bp.route('/scopes/', methods=['DELETE']) +@jwt_required() +@require_permission('geenforce.manage') +def delete_scope(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + db.session.delete(scope) + db.session.commit() + return success_response({'deleted': scopeid}) + + +# -- entry CRUD (geenforce.manage) -------------------------------------------- + +def _validate_entry(payload): + """Return an error string, or None if the entry payload is valid.""" + if not (payload.get('Name') or '').strip(): + return 'entry Name is required' + if payload.get('Type') not in ENTRY_TYPES: + return f'entry Type must be one of {ENTRY_TYPES}' + return None + + +@geenforce_bp.route('/scopes//entries', methods=['POST']) +@jwt_required() +@require_permission('geenforce.manage') +def create_entry(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + payload = request.get_json(silent=True) or {} + invalid = _validate_entry(payload) + if invalid: + return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400) + nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1 + entry = build_entry(payload, nextorder) + _apply_app_link(entry, payload) + scope.entries.append(entry) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, + 'an entry with that Name already exists in this scope', + http_code=400) + return success_response(_entry_payload(entry), http_code=201) + + +@geenforce_bp.route('/entries/', methods=['PUT']) +@jwt_required() +@require_permission('geenforce.manage') +def update_entry(entryid): + entry = db.session.get(ManifestEntry, entryid) + if not entry: + return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) + payload = request.get_json(silent=True) or {} + invalid = _validate_entry(payload) + if invalid: + return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400) + # Free the one-to-one InUseCheck (unique entryid) before populate re-inserts + # it, so the replacement does not collide with the old row mid-flush. + if entry.inusecheck is not None: + db.session.delete(entry.inusecheck) + entry.inusecheck = None + db.session.flush() + populate_entry(entry, payload) + _apply_app_link(entry, payload) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, + 'an entry with that Name already exists in this scope', + http_code=400) + return success_response(_entry_payload(entry)) + + +@geenforce_bp.route('/entries/', methods=['DELETE']) +@jwt_required() +@require_permission('geenforce.manage') +def delete_entry(entryid): + entry = db.session.get(ManifestEntry, entryid) + if not entry: + return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) + db.session.delete(entry) + db.session.commit() + return success_response({'deleted': entryid}) + + +@geenforce_bp.route('/scopes//entries/reorder', methods=['PUT']) +@jwt_required() +@require_permission('geenforce.manage') +def reorder_entries(scopeid): + """Set entry order from a list of entryids (the ordering contract).""" + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + order = (request.get_json(silent=True) or {}).get('order') or [] + owned = {e.entryid: e for e in scope.entries} + if set(order) != set(owned): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'order must list exactly this scope\'s entry ids', + http_code=400) + for position, entryid in enumerate(order): + owned[entryid].sortorder = position + db.session.commit() + return success_response({'order': order}) + + +# -- simulate: "what would this PC get" --------------------------------------- + +@geenforce_bp.route('/scopes//simulate', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def simulate_scope(scopeid): + """Which draft entries apply to a given machine profile, and why the rest + are filtered out (reuses the engine-mirror filters).""" + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + profile = { + 'pctype': request.args.get('pctype') or scope.scopename, + 'subtype': request.args.get('subtype'), + 'hostname': request.args.get('hostname'), + 'machinenumber': request.args.get('machinenumber'), + 'cmmversion': request.args.get('cmmversion'), + 'phase': scope.phase, + } + applied, filtered = [], [] + for entry in scope.entries: + entry_dict = entry_to_dict(entry) + if entry_applies(entry_dict, profile): + applied.append(entry.name) + else: + reasons = [] + strict_allowed = profile.get('phase') == 'preinstall' + if not matches_pctype(entry_dict, profile['pctype'], + profile['subtype'], strict_allowed): + reasons.append('PCTypes') + if not matches_hostname(entry_dict, profile['hostname']): + reasons.append('TargetHostnames') + if not matches_machinenumber(entry_dict, profile['machinenumber']): + reasons.append('TargetMachineNumbers') + if not matches_cmmversion(entry_dict, profile['cmmversion']): + reasons.append('_CmmVersion') + filtered.append({'name': entry.name, 'filteredby': reasons}) + return success_response({'profile': profile, 'applied': applied, + 'filtered': filtered}) + + +# -- compliance: "how much of the fleet has this app" ------------------------- + +@geenforce_bp.route('/scopes//compliance', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def scope_compliance(scopeid): + """Fleet-install coverage per app-linked entry (from collected PC data). + + One row per entry that carries a curated appid; installed/version-match + counts come from the computers plugin's ComputerInstalledApp. Degrades to + null counts (computersplugin: false) when that plugin is absent. + """ + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', + http_code=404) + return success_response(service.compliance_for_scope(scope)) + + +# -- inline payload upload / fetch (small scripts + configs) ------------------ + +PAYLOAD_MAX_BYTES = 1024 * 1024 + + +@geenforce_bp.route('/entries//payload', methods=['POST']) +@jwt_required() +@require_permission('geenforce.publish') +def upload_entry_payload(entryid): + """Store an inline payload (<= 1 MB) for an entry and point the entry at it.""" + entry = db.session.get(ManifestEntry, entryid) + if not entry: + return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) + uploaded = request.files.get('file') + if uploaded is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'a file is required', http_code=400) + rawbytes = uploaded.read() + if not rawbytes: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'payload is empty', http_code=400) + if len(rawbytes) > PAYLOAD_MAX_BYTES: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'payload exceeds 1 MB limit', http_code=400) + service.store_inline_payload(entry, uploaded.filename, + uploaded.mimetype, rawbytes) + db.session.commit() + return success_response(_entry_payload(entry), http_code=201) + + +@geenforce_bp.route('/entries//payload', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def download_entry_payload(entryid): + """Return the stored inline payload bytes for an entry (404 if none).""" + entry = db.session.get(ManifestEntry, entryid) + if not entry: + return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) + payload = ManifestPayload.query.filter_by(entryid=entryid).first() + if not payload: + return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload', + http_code=404) + return Response( + payload.payloadbytes, + mimetype=payload.contenttype or 'application/octet-stream', + headers={'Content-Disposition': + f'attachment; filename="{payload.filename}"'}) + + +# -- publish lifecycle (geenforce.publish) ------------------------------------ + +@geenforce_bp.route('/scopes//publish', methods=['POST']) +@jwt_required() +@require_permission('geenforce.publish') +def publish_scope_route(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + from flask_jwt_extended import get_jwt_identity + notes = (request.get_json(silent=True) or {}).get('notes') + try: + publishedby = int(get_jwt_identity()) + except (TypeError, ValueError): + publishedby = None + version = service.publish_scope(scope.scopename, scope.phase, + notes=notes, publishedby=publishedby) + db.session.commit() + return success_response({'versionnumber': version}, http_code=201) + + +@geenforce_bp.route('/scopes//versions', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def list_versions(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + versions = ManifestPublishedVersion.query.filter_by( + scopeid=scopeid).order_by( + ManifestPublishedVersion.versionnumber.desc()).all() + return success_response([{ + 'versionnumber': v.versionnumber, + 'iscurrent': v.iscurrent, + 'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None, + 'publishedby': v.publishedby, + 'notes': v.notes, + } for v in versions]) + + +@geenforce_bp.route('/scopes//versions/', + methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def get_version(scopeid, versionnumber): + version = ManifestPublishedVersion.query.filter_by( + scopeid=scopeid, versionnumber=versionnumber).first() + if not version: + return error_response(ErrorCodes.NOT_FOUND, 'No such version', + http_code=404) + import json as _json + return success_response({'versionnumber': version.versionnumber, + 'manifest': _json.loads(version.manifestjson)}) + + +@geenforce_bp.route('/config', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def get_config(): + """Plugin config: the on-share export root (for export-to-share).""" + setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first() + return success_response({'shareroot': setting.value if setting else ''}) + + +@geenforce_bp.route('/config', methods=['PUT']) +@jwt_required() +@require_permission('geenforce.publish') +def put_config(): + payload = request.get_json(silent=True) or {} + Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(), + valuetype='string', category='geenforce', + description='On-share export root for GE-Enforce manifests') + db.session.commit() + return success_response({'shareroot': (payload.get('shareroot') or '').strip()}) + + +@geenforce_bp.route('/scopes//export-share', methods=['POST']) +@jwt_required() +@require_permission('geenforce.publish') +def export_share_route(scopeid): + """Write the scope's current published JSON to the configured share root + (backing up the old file to _meta/history first).""" + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first() + shareroot = setting.value if setting else '' + if not shareroot: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Configure the share root first', http_code=400) + try: + path = service.export_scope_to_share(scope.scopename, scope.phase, + shareroot) + except (ValueError, OSError) as exc: + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400) + return success_response({'path': path}) + + +@geenforce_bp.route('/scopes//rollback', methods=['POST']) +@jwt_required() +@require_permission('geenforce.publish') +def rollback_scope_route(scopeid): + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) + versionnumber = (request.get_json(silent=True) or {}).get('versionnumber') + if versionnumber is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'versionnumber is required', http_code=400) + try: + service.rollback_scope(scope.scopename, scope.phase, int(versionnumber)) + db.session.commit() + except ValueError as exc: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400) + return success_response({'versionnumber': int(versionnumber)}) + + +def _current_published_version(scopename, phase): + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + return None + published = scope.publishedversions.filter_by(iscurrent=True).first() + return published.versionnumber if published else None + + +@geenforce_bp.route('/reports', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def list_reports(): + """Latest enforcement report per PC (fleet compliance view). + + Each row shows the applied vs latest published version (receivedlatest = the + PC picked up the update) and the install/skip/fail counts + status. + Optional filters: hostname, scopename. + """ + query = ManifestEnforcementReport.query.filter_by(iscurrent=True) + hostname = request.args.get('hostname') + scopename = request.args.get('scopename') + if hostname: + query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname)) + if scopename: + query = query.filter_by(scopename=scopename) + reports = query.order_by( + ManifestEnforcementReport.receivedat.desc()).all() + + latest_cache = {} + data = [] + for report in reports: + key = (report.scopename, report.phase) + if key not in latest_cache: + latest_cache[key] = _current_published_version(*key) + latest = latest_cache[key] + data.append({ + 'reportid': report.reportid, + 'hostname': report.hostname, + 'scopename': report.scopename, + 'phase': report.phase, + 'appliedversion': report.appliedversion, + 'latestversion': latest, + 'receivedlatest': (latest is not None + and report.appliedversion == latest), + 'enforcerversion': report.enforcerversion, + 'status': report.status, + 'installed': report.installedcount, + 'skipped': report.skippedcount, + 'failed': report.failedcount, + 'filtered': report.filteredcount, + 'lastcheckin': (report.lastcheckin.isoformat() + 'Z' + if report.lastcheckin else None), + 'receivedat': (report.receivedat.isoformat() + 'Z' + if report.receivedat else None), + }) + return success_response(data) + + +@geenforce_bp.route('/reports/', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def get_report(reportid): + """One report with per-entry outcomes (self-heal / failure detail).""" + report = db.session.get(ManifestEnforcementReport, reportid) + if not report: + return error_response(ErrorCodes.NOT_FOUND, 'No such report', + http_code=404) + latest = _current_published_version(report.scopename, report.phase) + return success_response({ + 'reportid': report.reportid, + 'hostname': report.hostname, + 'scopename': report.scopename, + 'phase': report.phase, + 'appliedversion': report.appliedversion, + 'latestversion': latest, + 'receivedlatest': (latest is not None + and report.appliedversion == latest), + 'status': report.status, + 'results': [{ + 'entryname': r.entryname, + 'action': r.action, + 'selfhealed': r.selfhealed, + 'exitcode': r.exitcode, + 'message': r.message, + } for r in report.results], + }) diff --git a/plugins/geenforce/client/Install-GEEnforce.ps1 b/plugins/geenforce/client/Install-GEEnforce.ps1 new file mode 100644 index 00000000..2f4b6e66 --- /dev/null +++ b/plugins/geenforce/client/Install-GEEnforce.ps1 @@ -0,0 +1,180 @@ +<# +.SYNOPSIS + Provision a PC for GE-Enforce + shopdb: write its identity (so it knows its PC + type and bay), deploy the shopdb client kit, and register the enforcement + scheduled task. Site-neutral and imaging-path independent - run it from a PXE + step, an OOBE provisioning package (ppkg), Intune, or by hand. + + This is the "set up the PC to determine its PC type and such" step. There is no + auto-detection: the caller supplies the type (and, where relevant, the bay + machine number and CMM version), and this script writes the files/registry the + GE-Enforce engine reads. + + This script does NOT contain the GE-Enforce engine (Install-FromManifest.ps1) + or dispatcher - those are the GE-Enforce framework's. Point at your copy with + -EngineSource, or ensure it is already present under -InstallRoot\lib. + +.PARAMETER PCType + The imaging PC type = the manifest scope this PC runs. One of the scope names + (e.g. gea-shopfloor-cmm, gea-shopfloor-collections) or a legacy alias the + engine maps. Written to C:\Enrollment\pc-type.txt. REQUIRED. + +.PARAMETER MachineNumber + The bay machine number (for TargetMachineNumbers gates). Written to + machine-number.txt as a fallback; the DNC registry MachineNo wins if present. + +.PARAMETER CmmVersion + CMM bays only: the resolved PC-DMIS version (2016/2019/2026) for _CmmVersion + gating. Written to C:\Enrollment\cmm\version.txt. Omit for non-CMM PCs. + +.PARAMETER CmmId + CMM bays only: the bay id. Written to C:\Enrollment\cmm\cmmid.txt. + +.PARAMETER ShareRoot + The GE-Enforce share root (UNC), written into site-config.json as + shopfloorShareRoot. + +.PARAMETER Site + Site name, written into site-config.json. + +.PARAMETER ShopdbUrl + shopdb base URL (scheme + host) for the fetch/report client. Written to + HKLM:\SOFTWARE\GE\ShopDB\BaseUrl. + +.PARAMETER ShopdbToken + A geenforce.fetch (+ geenforce.report) managed service token. Written to + HKLM:\SOFTWARE\GE\ShopDB\ApiToken. (In many sites Azure DSC provisions this + instead - omit here if so.) + +.PARAMETER EngineSource + Optional path (folder or share) containing the GE-Enforce engine to copy in: + expects GE-Enforce.ps1 and lib\Install-FromManifest.ps1. If omitted, the engine + is assumed already present under -InstallRoot. + +.PARAMETER InstallRoot + Where the client kit + engine live on the PC. Default C:\ProgramData\GE-Enforce. + +.PARAMETER TaskName + Scheduled task name. Default 'GE-Enforce'. + +.PARAMETER IntervalMinutes + How often the enforcement task repeats. Default 5. + +.PARAMETER NoTask + Provision identity + kit only; do not register the scheduled task. + +.EXAMPLE + .\Install-GEEnforce.ps1 -PCType gea-shopfloor-cmm -MachineNumber 0615 ` + -CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" ` + -ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx ` + -EngineSource \\server\share\dt\shopfloor\common +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$PCType, + [string]$MachineNumber, + [string]$CmmVersion, + [string]$CmmId, + [string]$ShareRoot, + [string]$Site, + [string]$ShopdbUrl, + [string]$ShopdbToken, + [string]$EngineSource, + [string]$InstallRoot = 'C:\ProgramData\GE-Enforce', + [string]$EnrollmentRoot = 'C:\Enrollment', + [string]$TaskName = 'GE-Enforce', + [int]$IntervalMinutes = 5, + [switch]$NoTask +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-TextFile { + param([string]$Path, [string]$Value) + $dir = Split-Path -Parent $Path + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + Set-Content -LiteralPath $Path -Value $Value -Encoding ascii -NoNewline + Write-Host " wrote $Path" +} + +try { + Write-Host "GE-Enforce provisioning: PCType=$PCType" + + # --- 1. Identity: what the PC is (what the engine reads) ----------------- + Write-TextFile (Join-Path $EnrollmentRoot 'pc-type.txt') $PCType + if ($MachineNumber) { Write-TextFile (Join-Path $EnrollmentRoot 'machine-number.txt') $MachineNumber } + if ($CmmVersion) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\version.txt') $CmmVersion } + if ($CmmId) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\cmmid.txt') $CmmId } + + $siteConfig = @{} + if ($ShareRoot) { $siteConfig['shopfloorShareRoot'] = $ShareRoot } + if ($Site) { $siteConfig['site'] = $Site } + if ($siteConfig.Count) { + $siteConfigPath = Join-Path $EnrollmentRoot 'site-config.json' + $dir = Split-Path -Parent $siteConfigPath + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + ($siteConfig | ConvertTo-Json) | Set-Content -LiteralPath $siteConfigPath -Encoding ascii + Write-Host " wrote $siteConfigPath" + } + + # --- 2. shopdb client registry config ------------------------------------ + if ($ShopdbUrl -or $ShopdbToken) { + $regPath = 'HKLM:\SOFTWARE\GE\ShopDB' + if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } + if ($ShopdbUrl) { Set-ItemProperty -Path $regPath -Name BaseUrl -Value $ShopdbUrl } + if ($ShopdbToken) { Set-ItemProperty -Path $regPath -Name ApiToken -Value $ShopdbToken } + Write-Host " wrote HKLM:\SOFTWARE\GE\ShopDB" + } + + # --- 3. Deploy the client kit (ships alongside this script) -------------- + if (-not (Test-Path $InstallRoot)) { New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null } + foreach ($file in 'ShopdbEnforceClient.psm1', 'Invoke-ShopdbEnforce.ps1') { + $src = Join-Path $PSScriptRoot $file + if (Test-Path $src) { + Copy-Item -Path $src -Destination (Join-Path $InstallRoot $file) -Force + Write-Host " deployed $file" + } else { + Write-Warning "client kit file not found next to this script: $file" + } + } + + # --- 4. Engine (referenced, not vendored) -------------------------------- + if ($EngineSource) { + Copy-Item -Path (Join-Path $EngineSource 'GE-Enforce.ps1') ` + -Destination (Join-Path $InstallRoot 'GE-Enforce.ps1') -Force -ErrorAction SilentlyContinue + $libDir = Join-Path $InstallRoot 'lib' + if (-not (Test-Path $libDir)) { New-Item -ItemType Directory -Path $libDir -Force | Out-Null } + Copy-Item -Path (Join-Path $EngineSource 'lib\Install-FromManifest.ps1') ` + -Destination (Join-Path $libDir 'Install-FromManifest.ps1') -Force -ErrorAction SilentlyContinue + Write-Host " copied engine from $EngineSource" + } + $enginePath = Join-Path $InstallRoot 'lib\Install-FromManifest.ps1' + if (-not (Test-Path $enginePath)) { + Write-Warning "engine not present at $enginePath - provide -EngineSource or place it there before enforcement runs." + } + + # --- 5. Scheduled task: run the enforcement client as SYSTEM ------------- + if (-not $NoTask) { + $runner = Join-Path $InstallRoot 'Invoke-ShopdbEnforce.ps1' + $arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$runner`" -Scope `"$PCType`" -EnginePath `"$enginePath`"" + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments + $triggers = @( + (New-ScheduledTaskTrigger -AtLogOn), + (New-ScheduledTaskTrigger -Once -At (Get-Date) ` + -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)) + ) + $principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries -StartWhenAvailable + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers ` + -Principal $principal -Settings $settings -Force | Out-Null + Write-Host " registered task '$TaskName' (at logon + every $IntervalMinutes min)" + } + + Write-Host "GE-Enforce provisioning complete." + exit 0 +} catch { + Write-Error "GE-Enforce provisioning FAILED: $($_.Exception.Message)" + exit 1 +} diff --git a/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 new file mode 100644 index 00000000..4128a821 --- /dev/null +++ b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 @@ -0,0 +1,103 @@ +<# +.SYNOPSIS + Reference orchestrator: source a GE-Enforce manifest from shopdb, run the + UNCHANGED engine against it, and report the result back to shopdb. + + This is a thin wrapper around Install-FromManifest.ps1 (the engine). It is a + reference a site adapts into its GE-Enforce.ps1 flow; it is not the live + dispatcher. The engine, detection, self-heal, and SMB payload resolution are + untouched - only the source of the manifest JSON moves from a share file to + shopdb, plus a result report. + +.PARAMETER Scope + The imaging pc-type / scope name (e.g. gea-shopfloor-cmm), same value the + dispatcher already resolves from C:\Enrollment\pc-type.txt. + +.PARAMETER EnginePath + Path to Install-FromManifest.ps1 (the engine lib, >= 2.6). + +.PARAMETER ShareManifestPath + The current on-share manifest for this scope. In shadow mode the engine runs + against THIS (unchanged behavior) and shopdb is only compared + reported. Once + cut over, omit it and the engine runs against the shopdb-sourced manifest. + +.PARAMETER ShadowMode + Fetch + compare + report, but install from the share (no behavior change). + +.NOTES + Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC. + Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$Scope, + [Parameter(Mandatory)] [string]$EnginePath, + [string]$ShareManifestPath, + [switch]$ShadowMode, + [string]$BaseUrl, + [string]$ApiToken, + [string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log" +) + +function Write-Log { + param([string]$Message, [string]$Level = 'INFO') + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" + try { Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue } catch {} + Write-Host $line +} + +try { + Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force + + $config = Get-ShopdbConfig -BaseUrl $BaseUrl -ApiToken $ApiToken + if (-not $config) { + Write-Log 'No shopdb BaseUrl/ApiToken configured yet - retry next cycle.' 'WARN' + exit 0 + } + + $sync = Sync-ShopdbManifest -Scope $Scope -Config $config + if (-not $sync.Path) { + Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN' + exit 0 + } + Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))." + + # Shadow mode: compare shopdb vs the share, but install from the share. + if ($ShadowMode -and $ShareManifestPath -and (Test-Path $ShareManifestPath)) { + $diff = Compare-ShopdbShadow -ShopdbManifestPath $sync.Path -ShareManifestPath $ShareManifestPath + if ($diff.Same) { + Write-Log 'Shadow: shopdb manifest matches the share.' + } else { + Write-Log ("Shadow DIFF: shopdb-only=[{0}] share-only=[{1}] orderDiff={2}" -f ` + ($diff.ShopdbOnly -join ','), ($diff.ShareOnly -join ','), $diff.OrderDiff) 'WARN' + } + } + + # Which manifest the engine actually runs against. + $manifestToRun = if ($ShadowMode -and $ShareManifestPath) { $ShareManifestPath } else { $sync.Path } + + # --- INTEGRATION POINT --------------------------------------------------- + # Run the engine. Install-FromManifest.ps1 is expected to return (or you + # adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered + # and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this + # to your engine's actual return/parse; the shape below is the contract. + Write-Log "Running engine against $manifestToRun" + $summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope + if (-not $summary) { + $summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() } + } + if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' } + + # Report the result (best-effort). + $appliedVersion = 0 + if ($sync.Version) { [int]::TryParse($sync.Version, [ref]$appliedVersion) | Out-Null } + $report = New-ShopdbReport -Scope $Scope -AppliedVersion $appliedVersion -Summary $summary + if (Send-ShopdbReport -Config $config -Report $report) { + Write-Log "Reported: installed=$($summary.Installed) skipped=$($summary.Skipped) failed=$($summary.Failed)." + } else { + Write-Log 'Report POST failed (non-fatal).' 'WARN' + } +} catch { + Write-Log "Unhandled error (non-fatal): $($_.Exception.Message)" 'ERROR' +} +exit 0 diff --git a/plugins/geenforce/client/ShopdbEnforceClient.psm1 b/plugins/geenforce/client/ShopdbEnforceClient.psm1 new file mode 100644 index 00000000..a84d2d56 --- /dev/null +++ b/plugins/geenforce/client/ShopdbEnforceClient.psm1 @@ -0,0 +1,164 @@ +<# +.SYNOPSIS + Client-side helpers for sourcing GE-Enforce manifests from shopdb and reporting + results back. Site-neutral reference kit - deploy alongside GE-Enforce; do NOT + hardcode any site here. + + This module does NOT replace Install-FromManifest.ps1 (the engine). It only + changes where the manifest JSON comes from (shopdb HTTP instead of a share + file) and adds a result report. The engine, detection, self-heal, and SMB + payload resolution stay exactly as they are. + + Resilience mirrors GE-Enforce: any failure is non-fatal (fail-safe). If shopdb + is unreachable the client enforces from the last-known-good cached manifest and + a PC is never left unmanaged because the web app is down. + + Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB + BaseUrl e.g. https://shopdb.example.geaerospace.net + ApiToken a geenforce.fetch (+ geenforce.report) managed service token, + provisioned the same way as SFLD creds (Azure DSC). +#> + +Set-StrictMode -Version Latest + +function Get-ShopdbConfig { + param([string]$BaseUrl, [string]$ApiToken) + $regPath = 'HKLM:\SOFTWARE\GE\ShopDB' + if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) { + $props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue + if (-not $BaseUrl -and $props.BaseUrl) { $BaseUrl = $props.BaseUrl } + if (-not $ApiToken -and $props.ApiToken) { $ApiToken = $props.ApiToken } + } + if (-not $BaseUrl -or -not $ApiToken) { return $null } + return @{ BaseUrl = $BaseUrl.TrimEnd('/'); ApiToken = $ApiToken } +} + +function Sync-ShopdbManifest { + <# + Fetch the current published manifest for a scope into a local cache, using + an ETag so an unchanged manifest is a cheap 304. On any network error, fall + back to the last-known-good cached copy. Returns: + @{ Path; Version; Source } where Source is + 'shopdb' | 'cache-304' | 'cache-lastgood' | $null (nothing available) + #> + param( + [Parameter(Mandatory)] [string]$Scope, + [Parameter(Mandatory)] [hashtable]$Config, + [string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce' + ) + if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null } + $manifestPath = Join-Path $CacheDir "$Scope.json" + $etagPath = Join-Path $CacheDir "$Scope.etag" + $headers = @{ 'X-API-Key' = $Config.ApiToken } + if (Test-Path $etagPath) { $headers['If-None-Match'] = (Get-Content -LiteralPath $etagPath -Raw).Trim() } + + $uri = "$($Config.BaseUrl)/api/geenforce/manifest?pctype=$([uri]::EscapeDataString($Scope))" + try { + $response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing ` + -TimeoutSec 30 -ErrorAction Stop + if ($response.StatusCode -eq 200) { + [System.IO.File]::WriteAllText($manifestPath, $response.Content) + # PowerShell 7 returns header values as string arrays; 5.1 as scalars. + # @(...)[0] yields a clean scalar in both. + $etag = @($response.Headers['ETag'])[0] + if ($etag) { + Set-Content -LiteralPath $etagPath -Value $etag -NoNewline + } + $version = @($response.Headers['X-Manifest-Version'])[0] + if ($version) { + Set-Content -LiteralPath (Join-Path $CacheDir "$Scope.version") -Value $version -NoNewline + } + return @{ Path = $manifestPath; Version = $version; Source = 'shopdb' } + } + } catch { + $status = $null + if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } + if ($status -eq 304 -and (Test-Path $manifestPath)) { + return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' } + } + # Network/other failure: fall back to last-known-good. + if (Test-Path $manifestPath) { + return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' } + } + return @{ Path = $null; Version = $null; Source = $null } + } + # 304 without exception (some PS versions) -> use cache. + if (Test-Path $manifestPath) { + return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' } + } + return @{ Path = $null; Version = $null; Source = $null } +} + +function Read-CachedVersion { + param([string]$CacheDir, [string]$Scope) + $verPath = Join-Path $CacheDir "$Scope.version" + if (Test-Path $verPath) { return (Get-Content -LiteralPath $verPath -Raw).Trim() } + return $null +} + +function Compare-ShopdbShadow { + <# + Shadow-mode comparison: do the shopdb manifest and the on-share manifest + select the same ordered entry names? Returns @{ Same; ShopdbOnly; ShareOnly; + OrderDiff }. Behavioral, not byte, comparison. + #> + param([Parameter(Mandatory)][string]$ShopdbManifestPath, + [Parameter(Mandatory)][string]$ShareManifestPath) + $shopdb = (Get-Content -LiteralPath $ShopdbManifestPath -Raw | ConvertFrom-Json) + $share = (Get-Content -LiteralPath $ShareManifestPath -Raw | ConvertFrom-Json) + $shopdbNames = @($shopdb.Applications | ForEach-Object { $_.Name }) + $shareNames = @($share.Applications | ForEach-Object { $_.Name }) + return @{ + Same = (($shopdbNames -join '|') -eq ($shareNames -join '|')) + ShopdbOnly = @($shopdbNames | Where-Object { $_ -notin $shareNames }) + ShareOnly = @($shareNames | Where-Object { $_ -notin $shopdbNames }) + OrderDiff = (($shopdbNames -join '|') -ne ($shareNames -join '|')) + } +} + +function Send-ShopdbReport { + <# + POST an enforcement report to shopdb. Best-effort: never throws, returns + $true on success. Report is a hashtable matching POST /api/geenforce/report. + #> + param([Parameter(Mandatory)][hashtable]$Config, + [Parameter(Mandatory)][hashtable]$Report) + try { + $body = ($Report | ConvertTo-Json -Depth 6) + Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" ` + -Method Post -Headers @{ 'X-API-Key' = $Config.ApiToken } ` + -ContentType 'application/json' -Body $body -TimeoutSec 30 -ErrorAction Stop | Out-Null + return $true + } catch { + return $false + } +} + +function New-ShopdbReport { + <# + Build a report payload from an engine summary. `Summary` is expected to + carry Installed/Skipped/Failed/Filtered counts and a Results list of + @{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own + per-entry outcomes into this at the call site. + #> + param([string]$Hostname = $env:COMPUTERNAME, + [Parameter(Mandatory)][string]$Scope, + [int]$AppliedVersion, + [Parameter(Mandatory)][hashtable]$Summary) + return @{ + hostname = $Hostname + scopename = $Scope + appliedversion = $AppliedVersion + enforcerversion = $Summary.EnforcerVersion + counts = @{ + installed = [int]$Summary.Installed + skipped = [int]$Summary.Skipped + failed = [int]$Summary.Failed + filtered = [int]$Summary.Filtered + } + results = @($Summary.Results) + } +} + +Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, ` + Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion diff --git a/plugins/geenforce/filters.py b/plugins/geenforce/filters.py new file mode 100644 index 00000000..7e1e3ca2 --- /dev/null +++ b/plugins/geenforce/filters.py @@ -0,0 +1,144 @@ +"""Python mirror of the GE-Enforce engine's targeting filters. + +These re-implement, field for field, the four filter functions in +`Install-FromManifest.ps1` (Test-PCTypeMatches, Test-HostnameMatches, +Test-MachineNumberMatches, Test-CmmVersionMatches) plus the PCTypes alias graph +(lines 463-475). They operate on plain manifest-entry dicts (the exact shape of +an Applications[] entry) and a machine-profile dict, so the SAME code answers +two questions: + + 1. Parity harness: do the on-share manifest and the shopdb-rebuilt manifest + select the same entries in the same order for a set of machine profiles? + 2. Simulator: "what would PC Y get" - which entries apply and why. + +The engine lib stays the single source of truth; this is a mirror. If the lib's +alias map changes, ALIAS_GROUPS here must change with it (the parity harness's +legacy-name profiles fail loudly on divergence). +""" + +import fnmatch + +# Mirror of $script:_pcTypeAliasGroups in Install-FromManifest.ps1:463-475. +# Rows are alias sets: names in the same set all match the same identity. +ALIAS_GROUPS = [ + ('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections', + 'gea-shopfloor-common'), + ('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'), + ('Standard-Timeclock', 'gea-shopfloor-common'), + ('CMM', 'gea-shopfloor-cmm'), + ('Keyence', 'gea-shopfloor-keyence'), + ('Lab', 'gea-shopfloor-common'), + ('WaxAndTrace', 'gea-shopfloor-waxtrace'), + ('Genspect', 'gea-shopfloor-genspect'), + ('Display', 'gea-shopfloor-display'), + ('Heattreat', 'gea-shopfloor-heattreat'), + ('PartMarker', 'gea-shopfloor-partmarker'), +] + + +def _alias_sets(name): + """Every alias group containing `name` (case-insensitive).""" + lname = (name or '').lower() + return [g for g in ALIAS_GROUPS if any(n.lower() == lname for n in g)] + + +def matches_pctype(entry, pctype, subtype=None, strict_allowed=False): + """Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection. + + PCTypesStrict=true disables alias expansion (both the PC's identity and the + manifest values are compared literally), so a 'collections'-only strict entry + does NOT match a 'nocollections' PC via their shared 'Standard' alias group. + IMPORTANT: only the PREINSTALL runner honors this flag - the runtime engine + (Install-FromManifest.ps1's Test-PCTypeMatches) has no strict handling. So + strict is applied only when strict_allowed (i.e. phase == 'preinstall'), + else a runtime entry with PCTypesStrict still alias-expands, matching the + real engine. + """ + values = entry.get('PCTypes') or [] + if not values: + return True + if not pctype: + return True + strict = strict_allowed and bool(entry.get('PCTypesStrict')) + # Names the current PC matches: bare type, "type-subtype", and (unless strict) + # all aliases of either. + mynames = set() + mynames.add(pctype.lower()) + seeds = [pctype] + if subtype: + combined = f'{pctype}-{subtype}' + mynames.add(combined.lower()) + seeds.append(combined) + if not strict: + for seed in seeds: + for group in _alias_sets(seed): + for alias in group: + mynames.add(alias.lower()) + for value in values: + if value == '*': + return True + if value.lower() in mynames: + return True + if strict: + continue + # The manifest value may itself be an alias - expand and check overlap. + for group in _alias_sets(value): + for alias in group: + if alias.lower() in mynames: + return True + return False + + +def matches_hostname(entry, hostname): + """Test-HostnameMatches: exact or PowerShell -like glob (*, ?, [set]).""" + patterns = entry.get('TargetHostnames') or [] + if not patterns: + return True + myname = (hostname or '').lower() + for pattern in patterns: + lpattern = (pattern or '').lower() + if lpattern == myname: + return True + if fnmatch.fnmatch(myname, lpattern): + return True + return False + + +def matches_machinenumber(entry, machinenumber): + """Test-MachineNumberMatches: filter present but PC has no number = exclude.""" + numbers = entry.get('TargetMachineNumbers') or [] + if not numbers: + return True + if not machinenumber: + return False + mynumber = str(machinenumber).lower() + return any(str(n).lower() == mynumber for n in numbers) + + +def matches_cmmversion(entry, cmmversion): + """Test-CmmVersionMatches: untagged always applies; no PC version = install-all.""" + tag = entry.get('_CmmVersion') + if not tag: + return True + if not cmmversion: + return True + return str(tag).lower() == str(cmmversion).lower() + + +def entry_applies(entry, profile): + """All four filters ANDed, exactly as the engine's main loop applies them. + + `profile` keys: pctype, subtype, hostname, machinenumber, cmmversion, and + optional phase ('preinstall' enables PCTypesStrict, matching the runners). + """ + strict_allowed = profile.get('phase') == 'preinstall' + return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype'), + strict_allowed) + and matches_hostname(entry, profile.get('hostname')) + and matches_machinenumber(entry, profile.get('machinenumber')) + and matches_cmmversion(entry, profile.get('cmmversion'))) + + +def applicable_entry_names(applications, profile): + """Ordered list of entry Names that pass all filters for a profile.""" + return [e.get('Name') for e in applications if entry_applies(e, profile)] diff --git a/plugins/geenforce/importer.py b/plugins/geenforce/importer.py new file mode 100644 index 00000000..684f588b --- /dev/null +++ b/plugins/geenforce/importer.py @@ -0,0 +1,117 @@ +"""Parse on-share manifest.json / preinstall.json into draft DB rows. + +The reverse of serializer.py. `build_scope` turns a parsed manifest dict into a +ManifestScope with its ordered entries and child rows (not yet committed). +`discover_share` walks a GE-Enforce share root and yields (scopename, phase, +manifest_dict) for common + every gea-shopfloor-* runtime manifest, skipping +`.bak` variants. `load_preinstall` reads a preinstall.json. + +Import is a rebuild: the caller replaces a scope's draft rows so re-running is +idempotent. +""" + +import json +import os + +from .models import ( + ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname, + ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess, +) +from .serializer import _SCALAR_FIELDS, _BOOL_FLAGS + +# manifest key -> model attr (reverse of the serializer's scalar map). +_KEY_TO_ATTR = {mk: attr for attr, mk in _SCALAR_FIELDS} +_FLAG_TO_ATTR = {mk: attr for attr, mk in _BOOL_FLAGS} + + +# Scalar model attributes that a populate resets (so an update clears fields the +# new payload omits). name/entrytype are required and set explicitly. +_RESETTABLE_ATTRS = [attr for _, attr in _SCALAR_FIELDS + if attr not in ('name', 'entrytype')] + ['comment', 'regvalue'] + + +def populate_entry(entry, entry_dict): + """Set every field + child on an existing (or new) ManifestEntry from one + Applications[] entry dict. Existing children are replaced. Returns entry.""" + entry.name = entry_dict.get('Name') + entry.entrytype = entry_dict.get('Type') + entry.comment = entry_dict.get('_comment') + for attr in _RESETTABLE_ATTRS: + if attr in ('comment', 'regvalue'): + continue + setattr(entry, attr, None) + for key, attr in _KEY_TO_ATTR.items(): + if attr in ('name', 'entrytype'): + continue + if key in entry_dict and entry_dict[key] is not None: + setattr(entry, attr, entry_dict[key]) + # RegValue stored as its raw JSON literal so DWord vs string typing survives. + entry.regvalue = (json.dumps(entry_dict['RegValue']) + if 'RegValue' in entry_dict else None) + for key, attr in _FLAG_TO_ATTR.items(): + setattr(entry, attr, bool(entry_dict.get(key))) + # Multi-value filters -> child rows (replace, preserve order). + entry.pctypes = [ManifestEntryPcType(sortorder=i, pctypevalue=value) + for i, value in enumerate(entry_dict.get('PCTypes') or [])] + entry.hostnames = [ManifestEntryHostname(sortorder=i, hostnamepattern=value) + for i, value in enumerate(entry_dict.get('TargetHostnames') or [])] + entry.machinenumbers = [ + ManifestEntryMachineNumber(sortorder=i, machinenumber=str(value)) + for i, value in enumerate(entry_dict.get('TargetMachineNumbers') or [])] + # InUseCheck object + Processes[]. + inuse = entry_dict.get('InUseCheck') + if inuse: + check = ManifestInUseCheck(behavior=inuse.get('Behavior')) + for i, proc in enumerate(inuse.get('Processes') or []): + check.processes.append(ManifestInUseCheckProcess( + sortorder=i, + processname=proc.get('Name'), + exepath=proc.get('ExePath'), + gracefulclosetimeoutsec=proc.get('GracefulCloseTimeoutSec'))) + entry.inusecheck = check + else: + entry.inusecheck = None + return entry + + +def build_entry(entry_dict, sortorder): + """Build a new ManifestEntry (+ children) from one Applications[] entry.""" + entry = ManifestEntry(sortorder=sortorder) + return populate_entry(entry, entry_dict) + + +def build_scope(scopename, phase, manifest_dict, iscommon=False): + """Build a ManifestScope (+ entries) from a parsed manifest dict.""" + scope = ManifestScope( + scopename=scopename, + phase=phase, + manifestversion=str(manifest_dict.get('Version', '1.0')), + topcomment=manifest_dict.get('_comment'), + site=manifest_dict.get('Site'), + iscommon=iscommon) + for i, entry_dict in enumerate(manifest_dict.get('Applications') or []): + scope.entries.append(build_entry(entry_dict, i)) + return scope + + +def discover_share(shareroot): + """Yield (scopename, phase, manifest_dict) for runtime manifests on a share. + + Reads `common/manifest.json` and every `gea-shopfloor-*/manifest.json`. + Skips `.bak` / `.pre-*.bak` variants (only the exact `manifest.json`). + """ + for name in sorted(os.listdir(shareroot)): + if name != 'common' and not name.startswith('gea-shopfloor-'): + continue + path = os.path.join(shareroot, name, 'manifest.json') + if not os.path.isfile(path): + continue + with open(path) as handle: + manifest = json.load(handle) + yield name, 'runtime', manifest + + +def load_manifest_file(path): + """Parse a single manifest.json / preinstall.json file.""" + with open(path) as handle: + return json.load(handle) diff --git a/plugins/geenforce/manifest.json b/plugins/geenforce/manifest.json new file mode 100644 index 00000000..35e50bf9 --- /dev/null +++ b/plugins/geenforce/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "geenforce", + "display_name": "GE-Enforce", + "version": "0.1.0", + "description": "GE-Enforce manifest store. Owns imaging PC-type scopes and their install manifests (apps, scripts, files, registry, version gates) as shopdb data, served to the GE-Enforce client as JSON. Requires GE-Enforce lib >= 2.6 on target PCs (the _CmmVersion gate).", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.7.0,<1.0.0", + "api_prefix": "/api/geenforce", + "default_enabled": false, + "provides": { + "features": ["manifest-store", "imaging-pc-types"] + } +} diff --git a/plugins/geenforce/migrations/env.py b/plugins/geenforce/migrations/env.py new file mode 100644 index 00000000..bb8998d8 --- /dev/null +++ b/plugins/geenforce/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the GE-Enforce plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which filters +the metadata to this plugin's tables and drives Alembic against the per-plugin +version table alembic_version_geenforce (ADR-008). This plugin is NEW: its 0001 +baseline really CREATES its tables, because the core chain never built them. +""" +import os + +os.environ['PLUGIN_NAME'] = 'geenforce' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/geenforce/migrations/script.py.mako b/plugins/geenforce/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/geenforce/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py b/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py new file mode 100644 index 00000000..2c9831f5 --- /dev/null +++ b/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py @@ -0,0 +1,241 @@ +"""geenforce plugin baseline (real create). + +This plugin was built after the ADR-008 ownership cutover, so this baseline +actually CREATES the plugin's tables (the core chain never knew about them). It +runs from `flask plugin install geenforce` / `flask plugin upgrade-all` after +`flask db upgrade` builds the core schema. All foreign keys are intra-plugin, so +tables are created parent-first. computertypeid / measuringtooltypeid / +publishedby are SOFT references (plain columns, no FK) to optional plugins/core. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'geenforce0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'manifestscopes', + sa.Column('scopeid', sa.Integer(), nullable=False), + sa.Column('scopename', sa.String(length=64), nullable=False), + sa.Column('phase', sa.String(length=16), nullable=False), + sa.Column('computertypeid', sa.Integer(), nullable=True), + sa.Column('measuringtooltypeid', sa.Integer(), nullable=True), + sa.Column('manifestversion', sa.String(length=16), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('topcomment', sa.Text(), nullable=True), + sa.Column('site', sa.String(length=100), nullable=True), + sa.Column('iscommon', sa.Boolean(), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('scopeid'), + sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'), + ) + op.create_table( + 'manifestentries', + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('scopeid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('entrytype', sa.String(length=16), nullable=False), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('installer', sa.String(length=255), nullable=True), + sa.Column('installargs', sa.Text(), nullable=True), + sa.Column('scriptpath', sa.String(length=255), nullable=True), + sa.Column('scriptargs', sa.String(length=255), nullable=True), + sa.Column('sourcepath', sa.String(length=255), nullable=True), + sa.Column('destination', sa.String(length=255), nullable=True), + sa.Column('regpath', sa.String(length=255), nullable=True), + sa.Column('regname', sa.String(length=128), nullable=True), + sa.Column('regvalue', sa.Text(), nullable=True), + sa.Column('regtype', sa.String(length=16), nullable=True), + sa.Column('payloadsource', sa.String(length=8), nullable=False), + sa.Column('payloadref', sa.String(length=512), nullable=True), + sa.Column('payloadsha256', sa.String(length=64), nullable=True), + sa.Column('detectionmethod', sa.String(length=16), nullable=True), + sa.Column('detectionpath', sa.String(length=255), nullable=True), + sa.Column('detectionname', sa.String(length=128), nullable=True), + sa.Column('detectionvalue', sa.String(length=255), nullable=True), + sa.Column('detectionpattern', sa.String(length=255), nullable=True), + sa.Column('cmmversion', sa.String(length=16), nullable=True), + sa.Column('logfile', sa.String(length=255), nullable=True), + sa.Column('waittimeoutsec', sa.Integer(), nullable=True), + sa.Column('applymode', sa.String(length=16), nullable=True), + sa.Column('updatewindow', sa.String(length=11), nullable=True), + sa.Column('appid', sa.Integer(), nullable=True), + sa.Column('preenrollment', sa.Boolean(), nullable=False), + sa.Column('killafterdetection', sa.Boolean(), nullable=False), + sa.Column('pctypesstrict', sa.Boolean(), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entryid'), + sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'), + ) + op.create_index('idx_entry_scope', 'manifestentries', ['scopeid']) + op.create_index('idx_entry_scope_order', 'manifestentries', + ['scopeid', 'sortorder']) + op.create_table( + 'manifestentrypctypes', + sa.Column('entrypctypeid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('pctypevalue', sa.String(length=64), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entrypctypeid'), + ) + op.create_index('idx_pctype_entry', 'manifestentrypctypes', ['entryid']) + op.create_table( + 'manifestentryhostnames', + sa.Column('entryhostnameid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('hostnamepattern', sa.String(length=64), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entryhostnameid'), + ) + op.create_index('idx_hostname_entry', 'manifestentryhostnames', ['entryid']) + op.create_table( + 'manifestentrymachinenumbers', + sa.Column('entrymachinenumberid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('machinenumber', sa.String(length=16), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entrymachinenumberid'), + ) + op.create_index('idx_machinenumber_entry', 'manifestentrymachinenumbers', + ['entryid']) + op.create_table( + 'manifestinusechecks', + sa.Column('inusecheckid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('behavior', sa.String(length=20), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('inusecheckid'), + sa.UniqueConstraint('entryid'), + ) + op.create_table( + 'manifestinusecheckprocesses', + sa.Column('inusecheckprocessid', sa.Integer(), nullable=False), + sa.Column('inusecheckid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('processname', sa.String(length=64), nullable=False), + sa.Column('exepath', sa.String(length=255), nullable=True), + sa.Column('gracefulclosetimeoutsec', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['inusecheckid'], + ['manifestinusechecks.inusecheckid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('inusecheckprocessid'), + ) + op.create_index('idx_process_check', 'manifestinusecheckprocesses', + ['inusecheckid']) + op.create_table( + 'manifestpublishedversions', + sa.Column('publishedversionid', sa.Integer(), nullable=False), + sa.Column('scopeid', sa.Integer(), nullable=False), + sa.Column('versionnumber', sa.Integer(), nullable=False), + sa.Column('manifestjson', sa.Text(length=16777215), nullable=False), + sa.Column('publishedat', sa.DateTime(), nullable=False), + sa.Column('publishedby', sa.Integer(), nullable=True), + sa.Column('iscurrent', sa.Boolean(), nullable=False), + sa.Column('notes', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('publishedversionid'), + sa.UniqueConstraint('scopeid', 'versionnumber', + name='uq_published_scope_version'), + ) + op.create_index('idx_published_scope', 'manifestpublishedversions', + ['scopeid']) + op.create_table( + 'manifestpayloads', + sa.Column('payloadid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('contenttype', sa.String(length=128), nullable=True), + sa.Column('payloadbytes', sa.LargeBinary(length=16777215), nullable=False), + sa.Column('payloadsha256', sa.String(length=64), nullable=False), + sa.Column('uploadedat', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('payloadid'), + ) + op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid']) + op.create_table( + 'manifestenforcementreports', + sa.Column('reportid', sa.Integer(), nullable=False), + sa.Column('hostname', sa.String(length=100), nullable=False), + sa.Column('scopename', sa.String(length=64), nullable=False), + sa.Column('phase', sa.String(length=16), nullable=False), + sa.Column('appliedversion', sa.Integer(), nullable=True), + sa.Column('enforcerversion', sa.String(length=20), nullable=True), + sa.Column('installedcount', sa.Integer(), nullable=False), + sa.Column('skippedcount', sa.Integer(), nullable=False), + sa.Column('failedcount', sa.Integer(), nullable=False), + sa.Column('filteredcount', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('lastcheckin', sa.DateTime(), nullable=True), + sa.Column('receivedat', sa.DateTime(), nullable=False), + sa.Column('iscurrent', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('reportid'), + ) + op.create_index('idx_report_host', 'manifestenforcementreports', + ['hostname']) + op.create_index('idx_report_current', 'manifestenforcementreports', + ['iscurrent']) + op.create_index('idx_report_host_scope', 'manifestenforcementreports', + ['hostname', 'scopename', 'phase']) + op.create_table( + 'manifestenforcementresults', + sa.Column('resultid', sa.Integer(), nullable=False), + sa.Column('reportid', sa.Integer(), nullable=False), + sa.Column('entryname', sa.String(length=128), nullable=False), + sa.Column('action', sa.String(length=16), nullable=False), + sa.Column('selfhealed', sa.Boolean(), nullable=False), + sa.Column('exitcode', sa.Integer(), nullable=True), + sa.Column('message', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['reportid'], + ['manifestenforcementreports.reportid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('resultid'), + ) + op.create_index('idx_result_report', 'manifestenforcementresults', + ['reportid']) + op.create_table( + 'pctypealiases', + sa.Column('aliasid', sa.Integer(), nullable=False), + sa.Column('aliasgroup', sa.Integer(), nullable=False), + sa.Column('aliasname', sa.String(length=64), nullable=False), + sa.PrimaryKeyConstraint('aliasid'), + sa.UniqueConstraint('aliasgroup', 'aliasname', + name='uq_alias_group_name'), + ) + op.create_index('idx_alias_group', 'pctypealiases', ['aliasgroup']) + + +def downgrade(): + op.drop_table('pctypealiases') + op.drop_table('manifestenforcementresults') + op.drop_table('manifestenforcementreports') + op.drop_table('manifestpayloads') + op.drop_table('manifestpublishedversions') + op.drop_table('manifestinusecheckprocesses') + op.drop_table('manifestinusechecks') + op.drop_table('manifestentrymachinenumbers') + op.drop_table('manifestentryhostnames') + op.drop_table('manifestentrypctypes') + op.drop_table('manifestentries') + op.drop_table('manifestscopes') diff --git a/plugins/geenforce/models/__init__.py b/plugins/geenforce/models/__init__.py new file mode 100644 index 00000000..bf3e31af --- /dev/null +++ b/plugins/geenforce/models/__init__.py @@ -0,0 +1,45 @@ +"""GE-Enforce plugin models.""" + +from .manifest import ( + ManifestScope, + ManifestEntry, + ManifestEntryPcType, + ManifestEntryHostname, + ManifestEntryMachineNumber, + ManifestInUseCheck, + ManifestInUseCheckProcess, + ManifestPublishedVersion, + ManifestPayload, + ManifestEnforcementReport, + ManifestEnforcementResult, + PcTypeAlias, + PHASES, + ENTRY_TYPES, + REG_TYPES, + PAYLOAD_SOURCES, + DETECTION_METHODS, + APPLY_MODES, + INUSE_BEHAVIORS, +) + +__all__ = [ + 'ManifestScope', + 'ManifestEntry', + 'ManifestEntryPcType', + 'ManifestEntryHostname', + 'ManifestEntryMachineNumber', + 'ManifestInUseCheck', + 'ManifestInUseCheckProcess', + 'ManifestPublishedVersion', + 'ManifestPayload', + 'ManifestEnforcementReport', + 'ManifestEnforcementResult', + 'PcTypeAlias', + 'PHASES', + 'ENTRY_TYPES', + 'REG_TYPES', + 'PAYLOAD_SOURCES', + 'DETECTION_METHODS', + 'APPLY_MODES', + 'INUSE_BEHAVIORS', +] diff --git a/plugins/geenforce/models/manifest.py b/plugins/geenforce/models/manifest.py new file mode 100644 index 00000000..75e41dfb --- /dev/null +++ b/plugins/geenforce/models/manifest.py @@ -0,0 +1,353 @@ +"""GE-Enforce manifest-store models. + +The manifest that GE-Enforce runs on each PC becomes shopdb data. A +`ManifestScope` is one imaging PC type (a `gea-shopfloor-*` scope, the `common` +fleet-wide scope, or the single flat `preinstall` scope). Each scope owns an +ordered list of `ManifestEntry` rows - one per Applications[] entry in the +on-share manifest.json - plus child rows for the multi-value targeting filters +and the nested InUseCheck. + +Design choices (see the ge-enforce-plugin proposal in the project wiki): +- ONE wide entries table with an `entrytype` discriminator and nullable + per-type columns. Not STI, not a JSON blob: the whole fleet is ~64 entries, + so sparse columns are free and every field stays queryable + plain-SQL + readable by IT. +- Array order IS execution order, so `sortorder` is a real column and the + contract; config-restore entries sit after their installer on purpose. +- `computertypeid` / `measuringtooltypeid` are SOFT references (plain Integer, + no DB foreign key) to other plugins' tables, which may not be installed. +- Published manifests are immutable snapshots that freeze the rendered JSON + document (`ManifestPublishedVersion`); the draft (`ManifestEntry`) is never + served to a client. +""" + +from shopdb.api import db, BaseModel + + +# Allowed enumerations, validated in the API/service layer (stored as strings +# for portability, matching the rest of the codebase). +PHASES = ('runtime', 'preinstall') +ENTRY_TYPES = ('MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry') +REG_TYPES = ('String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary') +PAYLOAD_SOURCES = ('smb', 'http', 'inline') +DETECTION_METHODS = ('Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile', + 'ValueMatches', 'pnputil', 'Always') +APPLY_MODES = ('Nightly', 'Immediate', 'ImmediateReboot') +INUSE_BEHAVIORS = ('Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot') + + +class ManifestScope(BaseModel): + """One imaging PC type / manifest scope (the working/draft head).""" + __tablename__ = 'manifestscopes' + + scopeid = db.Column(db.Integer, primary_key=True) + # 'common', 'gea-shopfloor-cmm', 'preinstall', ... + scopename = db.Column(db.String(64), nullable=False) + # 'runtime' (per-pctype manifest.json) or 'preinstall' (one flat manifest) + phase = db.Column(db.String(16), nullable=False, default='runtime') + # Soft refs (no FK): the plugins that own these tables are optional. + computertypeid = db.Column(db.Integer, nullable=True) + measuringtooltypeid = db.Column(db.Integer, nullable=True) + manifestversion = db.Column(db.String(16), nullable=False, default='1.0') + description = db.Column(db.String(255), nullable=True) + # The manifest-level '_comment' (documentation), preserved verbatim so + # export-to-share round-trips it. Excluded from behavioral parity. + topcomment = db.Column(db.Text, nullable=True) + iscommon = db.Column(db.Boolean, nullable=False, default=False) + # Preinstall manifests carry a top-level 'Site' field. + site = db.Column(db.String(100), nullable=True) + + entries = db.relationship( + 'ManifestEntry', back_populates='scope', + cascade='all, delete-orphan', order_by='ManifestEntry.sortorder', + lazy='selectin') + publishedversions = db.relationship( + 'ManifestPublishedVersion', back_populates='scope', + cascade='all, delete-orphan', + order_by='ManifestPublishedVersion.versionnumber', lazy='dynamic') + + __table_args__ = ( + db.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'), + ) + + def __repr__(self): + return f"" + + +class ManifestEntry(BaseModel): + """One Applications[] entry (draft). Wide table, entrytype discriminator.""" + __tablename__ = 'manifestentries' + + entryid = db.Column(db.Integer, primary_key=True) + scopeid = db.Column( + db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + name = db.Column(db.String(128), nullable=False) + entrytype = db.Column(db.String(16), nullable=False) + comment = db.Column(db.Text, nullable=True) # manifest '_comment' + + # Type-specific payload references (nullable; validated per entrytype). + installer = db.Column(db.String(255), nullable=True) + installargs = db.Column(db.Text, nullable=True) # can run long + scriptpath = db.Column(db.String(255), nullable=True) # 'Script' + scriptargs = db.Column(db.String(255), nullable=True) # 'Args' + sourcepath = db.Column(db.String(255), nullable=True) # 'Source' + destination = db.Column(db.String(255), nullable=True) + regpath = db.Column(db.String(255), nullable=True) + regname = db.Column(db.String(128), nullable=True) + # Raw JSON literal preserved verbatim (1 vs "1"); DWord typing depends on it. + regvalue = db.Column(db.Text, nullable=True) + regtype = db.Column(db.String(16), nullable=True) + + # Payload transport + integrity (integrity hash is SEPARATE from detection). + payloadsource = db.Column(db.String(8), nullable=False, default='smb') + payloadref = db.Column(db.String(512), nullable=True) + payloadsha256 = db.Column(db.String(64), nullable=True) + + # Detection (decides whether the action fires / self-heals). + detectionmethod = db.Column(db.String(16), nullable=True) + detectionpath = db.Column(db.String(255), nullable=True) + detectionname = db.Column(db.String(128), nullable=True) + detectionvalue = db.Column(db.String(255), nullable=True) + detectionpattern = db.Column(db.String(255), nullable=True) + + # Gates + control. + cmmversion = db.Column(db.String(16), nullable=True) # '_CmmVersion' + logfile = db.Column(db.String(255), nullable=True) + waittimeoutsec = db.Column(db.Integer, nullable=True) + applymode = db.Column(db.String(16), nullable=True) # inert in engine today + updatewindow = db.Column(db.String(11), nullable=True) # 'HH:MM-HH:MM', inert + + # Optional CURATED link to a core Applications catalog row (soft ref, no FK + # to keep the plugin decoupled). shopdb metadata only - NOT part of the + # manifest JSON the engine reads, so it never affects enforcement or parity. + appid = db.Column(db.Integer, nullable=True) + + # Preinstall-only flags. + preenrollment = db.Column(db.Boolean, nullable=False, default=False) + killafterdetection = db.Column(db.Boolean, nullable=False, default=False) + pctypesstrict = db.Column(db.Boolean, nullable=False, default=False) + + scope = db.relationship('ManifestScope', back_populates='entries') + pctypes = db.relationship( + 'ManifestEntryPcType', back_populates='entry', + cascade='all, delete-orphan', order_by='ManifestEntryPcType.sortorder', + lazy='selectin') + hostnames = db.relationship( + 'ManifestEntryHostname', back_populates='entry', + cascade='all, delete-orphan', order_by='ManifestEntryHostname.sortorder', + lazy='selectin') + machinenumbers = db.relationship( + 'ManifestEntryMachineNumber', back_populates='entry', + cascade='all, delete-orphan', + order_by='ManifestEntryMachineNumber.sortorder', lazy='selectin') + inusecheck = db.relationship( + 'ManifestInUseCheck', back_populates='entry', uselist=False, + cascade='all, delete-orphan', lazy='selectin') + + __table_args__ = ( + db.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'), + db.Index('idx_entry_scope_order', 'scopeid', 'sortorder'), + ) + + def __repr__(self): + return f"" + + +class ManifestEntryPcType(db.Model): + """One value of an entry's PCTypes filter (verbatim, incl. '*' and aliases).""" + __tablename__ = 'manifestentrypctypes' + + entrypctypeid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + pctypevalue = db.Column(db.String(64), nullable=False) + + entry = db.relationship('ManifestEntry', back_populates='pctypes') + + +class ManifestEntryHostname(db.Model): + """One value of an entry's TargetHostnames filter (wildcards kept verbatim).""" + __tablename__ = 'manifestentryhostnames' + + entryhostnameid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + hostnamepattern = db.Column(db.String(64), nullable=False) + + entry = db.relationship('ManifestEntry', back_populates='hostnames') + + +class ManifestEntryMachineNumber(db.Model): + """One value of an entry's TargetMachineNumbers filter.""" + __tablename__ = 'manifestentrymachinenumbers' + + entrymachinenumberid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + machinenumber = db.Column(db.String(16), nullable=False) + + entry = db.relationship('ManifestEntry', back_populates='machinenumbers') + + +class ManifestInUseCheck(db.Model): + """The InUseCheck object on an entry (0..1), with its Processes[] children.""" + __tablename__ = 'manifestinusechecks' + + inusecheckid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, unique=True) + behavior = db.Column(db.String(20), nullable=False) + + entry = db.relationship('ManifestEntry', back_populates='inusecheck') + processes = db.relationship( + 'ManifestInUseCheckProcess', back_populates='inusecheck', + cascade='all, delete-orphan', + order_by='ManifestInUseCheckProcess.sortorder', lazy='selectin') + + +class ManifestInUseCheckProcess(db.Model): + """One process in an InUseCheck's Processes[] list.""" + __tablename__ = 'manifestinusecheckprocesses' + + inusecheckprocessid = db.Column(db.Integer, primary_key=True) + inusecheckid = db.Column( + db.Integer, + db.ForeignKey('manifestinusechecks.inusecheckid', ondelete='CASCADE'), + nullable=False, index=True) + sortorder = db.Column(db.Integer, nullable=False, default=0) + processname = db.Column(db.String(64), nullable=False) + exepath = db.Column(db.String(255), nullable=True) + # Null = engine default (10); do not bake the default into the row. + gracefulclosetimeoutsec = db.Column(db.Integer, nullable=True) + + inusecheck = db.relationship('ManifestInUseCheck', back_populates='processes') + + +class ManifestPublishedVersion(db.Model): + """Immutable published snapshot: the frozen rendered JSON document. + + The client is always served the `iscurrent` snapshot for a scope, never the + live draft. Rollback flips `iscurrent`. Freezing the text (not row-mirroring) + makes immutability structural and rollback a one-flag change. + """ + __tablename__ = 'manifestpublishedversions' + + publishedversionid = db.Column(db.Integer, primary_key=True) + scopeid = db.Column( + db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'), + nullable=False, index=True) + versionnumber = db.Column(db.Integer, nullable=False) + # MEDIUMTEXT on MySQL (a full scope with comments can exceed 64 KB TEXT). + manifestjson = db.Column(db.Text(length=16777215), nullable=False) + publishedat = db.Column(db.DateTime, nullable=False) + publishedby = db.Column(db.Integer, nullable=True) # soft ref to users + iscurrent = db.Column(db.Boolean, nullable=False, default=False) + notes = db.Column(db.String(255), nullable=True) + + scope = db.relationship('ManifestScope', back_populates='publishedversions') + + __table_args__ = ( + db.UniqueConstraint('scopeid', 'versionnumber', + name='uq_published_scope_version'), + ) + + +class ManifestPayload(db.Model): + """Inline payload bytes for payloadsource='inline' (small scripts/configs).""" + __tablename__ = 'manifestpayloads' + + payloadid = db.Column(db.Integer, primary_key=True) + entryid = db.Column( + db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'), + nullable=False, index=True) + filename = db.Column(db.String(255), nullable=False) + contenttype = db.Column(db.String(128), nullable=True) + payloadbytes = db.Column(db.LargeBinary(length=16777215), nullable=False) + payloadsha256 = db.Column(db.String(64), nullable=False) + uploadedat = db.Column(db.DateTime, nullable=False) + + +class ManifestEnforcementReport(db.Model): + """One enforcement cycle reported by a PC (observed state). + + Each cycle the client POSTs its result for a scope: the published version it + actually applied (so shopdb knows whether the PC RECEIVED the latest update), + the enforcer version, and the installed/skipped/failed/filtered counts. The + latest report per (hostname, scopename, phase) carries `iscurrent`; older + ones are history. Pairs desired state (the manifest) with observed state. + """ + __tablename__ = 'manifestenforcementreports' + + reportid = db.Column(db.Integer, primary_key=True) + hostname = db.Column(db.String(100), nullable=False, index=True) + scopename = db.Column(db.String(64), nullable=False) + phase = db.Column(db.String(16), nullable=False, default='runtime') + # Published version the client actually ran; compare to the scope's current + # published version to see whether this PC received the latest manifest. + appliedversion = db.Column(db.Integer, nullable=True) + enforcerversion = db.Column(db.String(20), nullable=True) + installedcount = db.Column(db.Integer, nullable=False, default=0) + skippedcount = db.Column(db.Integer, nullable=False, default=0) + failedcount = db.Column(db.Integer, nullable=False, default=0) + filteredcount = db.Column(db.Integer, nullable=False, default=0) + # 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure). + status = db.Column(db.String(16), nullable=False, default='ok') + lastcheckin = db.Column(db.DateTime, nullable=True) # PC-reported time + receivedat = db.Column(db.DateTime, nullable=False) # server time + iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True) + + results = db.relationship( + 'ManifestEnforcementResult', back_populates='report', + cascade='all, delete-orphan', lazy='selectin') + + __table_args__ = ( + db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'), + ) + + +class ManifestEnforcementResult(db.Model): + """One entry's outcome within an enforcement cycle (self-heal detail).""" + __tablename__ = 'manifestenforcementresults' + + resultid = db.Column(db.Integer, primary_key=True) + reportid = db.Column( + db.Integer, + db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'), + nullable=False, index=True) + entryname = db.Column(db.String(128), nullable=False) + # 'installed' (action fired - a self-heal when it should already be present), + # 'skipped' (detected present), 'failed', 'filtered'. + action = db.Column(db.String(16), nullable=False) + # True when this install was a drift correction (self-heal), not a first + # install. Client-supplied; defaults to whether the action installed. + selfhealed = db.Column(db.Boolean, nullable=False, default=False) + exitcode = db.Column(db.Integer, nullable=True) + message = db.Column(db.Text, nullable=True) # warning / error text + + report = db.relationship('ManifestEnforcementReport', back_populates='results') + + +class PcTypeAlias(db.Model): + """Mirror of the engine lib's PCTypes alias graph (Install-FromManifest.ps1). + + Rows sharing an `aliasgroup` are one alias set. Server-side resolve/validate + only; the engine lib stays the single source of truth (never inverted). + """ + __tablename__ = 'pctypealiases' + + aliasid = db.Column(db.Integer, primary_key=True) + aliasgroup = db.Column(db.Integer, nullable=False, index=True) + aliasname = db.Column(db.String(64), nullable=False) + + __table_args__ = ( + db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'), + ) diff --git a/plugins/geenforce/parity.py b/plugins/geenforce/parity.py new file mode 100644 index 00000000..ba7ca5ad --- /dev/null +++ b/plugins/geenforce/parity.py @@ -0,0 +1,106 @@ +"""P1 behavioral-parity harness (Gate A). + +Proves the model is lossless WITHOUT byte-diffing: for each manifest, import it +into in-memory rows and render it back out, then check that + + 1. every entry is field-for-field identical (engine-relevant fields only, + dropping _comment and key order), in the same order, AND + 2. the same entries fire in the same order for a set of machine-profile + fixtures, using the same filter logic the engine uses (filters.py). + +Runs with no database (build_scope produces detached ORM objects whose +relationship lists are read directly), so it is cheap to re-run and wrap in CI. +Output is one readable line per scope. +""" + +import json +import os + +from .importer import build_scope +from .serializer import scope_to_manifest, canonical_entry +from .filters import applicable_entry_names + +_FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'parityfixtures.json') + + +def load_fixtures(): + with open(_FIXTURES_PATH) as handle: + return json.load(handle) + + +def check_scope(scopename, phase, original, fixtures): + """Compare an original manifest against its import+export round-trip.""" + rebuilt = scope_to_manifest(build_scope(scopename, phase, original)) + orig_apps = original.get('Applications') or [] + rebuilt_apps = rebuilt.get('Applications') or [] + + # Check 0: scope-level fields (Version, Site) round-trip. + firstdiff = None + for key in ('Version', 'Site'): + if str(original.get(key) or '') != str(rebuilt.get(key) or ''): + firstdiff = (f'scope field {key}: ' + f'{original.get(key)!r} vs {rebuilt.get(key)!r}') + break + + scope_ok = firstdiff is None + + # Check 1: field-identical, order-preserving. + identical = 0 + if firstdiff is None and len(orig_apps) != len(rebuilt_apps): + firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}') + for i in range(min(len(orig_apps), len(rebuilt_apps))): + co = canonical_entry(orig_apps[i]) + cr = canonical_entry(rebuilt_apps[i]) + if co == cr: + identical += 1 + elif firstdiff is None: + name = orig_apps[i].get('Name', f'#{i}') + diffkeys = sorted( + k for k in set(co) | set(cr) if co.get(k) != cr.get(k)) + firstdiff = f'entry "{name}" differs on {diffkeys}' + + # Check 2: same entries fire in the same order for each profile. Inject the + # scope phase so PCTypesStrict is honored only for preinstall (see filters). + profiles_same = 0 + for base_profile in fixtures: + profile = {**base_profile, 'phase': phase} + if (applicable_entry_names(orig_apps, profile) + == applicable_entry_names(rebuilt_apps, profile)): + profiles_same += 1 + elif firstdiff is None: + firstdiff = f'filter mismatch for profile {profile.get("label")}' + + passed = (scope_ok + and identical == len(orig_apps) == len(rebuilt_apps) + and profiles_same == len(fixtures)) + return { + 'scopename': scopename, + 'phase': phase, + 'entries_total': len(orig_apps), + 'entries_identical': identical, + 'profiles_total': len(fixtures), + 'profiles_same': profiles_same, + 'passed': passed, + 'firstdiff': firstdiff, + } + + +def run_parity(manifests, fixtures=None): + """Run parity over a list of (scopename, phase, manifest_dict). Returns + (results, all_passed).""" + fixtures = fixtures if fixtures is not None else load_fixtures() + results = [check_scope(name, phase, manifest, fixtures) + for name, phase, manifest in manifests] + return results, all(r['passed'] for r in results) + + +def format_result(result): + """One human-readable line for a scope result.""" + status = 'PASS' if result['passed'] else 'FAIL' + line = (f"scope {result['scopename']:<28} " + f"entries {result['entries_identical']}/{result['entries_total']} identical " + f"profiles {result['profiles_same']}/{result['profiles_total']} same-fire " + f"{status}") + if not result['passed'] and result['firstdiff']: + line += f"\n -> {result['firstdiff']}" + return line diff --git a/plugins/geenforce/parityfixtures.json b/plugins/geenforce/parityfixtures.json new file mode 100644 index 00000000..2ec3045b --- /dev/null +++ b/plugins/geenforce/parityfixtures.json @@ -0,0 +1,20 @@ +[ + {"label": "collections-okuma-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0615", "machinenumber": "3201", "cmmversion": ""}, + {"label": "collections-mtconnect-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC7501", "machinenumber": "7501", "cmmversion": ""}, + {"label": "collections-other-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0001", "machinenumber": "0001", "cmmversion": ""}, + {"label": "nocollections", "pctype": "gea-shopfloor-nocollections", "subtype": "", "hostname": "WJPC2000", "machinenumber": "", "cmmversion": ""}, + {"label": "common-timeclock", "pctype": "gea-shopfloor-common", "subtype": "", "hostname": "WJPC3000", "machinenumber": "", "cmmversion": ""}, + {"label": "cmm-2016", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM01", "machinenumber": "9101", "cmmversion": "2016"}, + {"label": "cmm-2019", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM02", "machinenumber": "9102", "cmmversion": "2019"}, + {"label": "cmm-2026", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM11", "machinenumber": "9111", "cmmversion": "2026"}, + {"label": "cmm-noversion-legacy", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM99", "machinenumber": "9199", "cmmversion": ""}, + {"label": "keyence-vr6000", "pctype": "Keyence", "subtype": "vr6000", "hostname": "WJKEY01", "machinenumber": "8001", "cmmversion": ""}, + {"label": "keyence-vr3000", "pctype": "Keyence", "subtype": "vr3000", "hostname": "WJKEY02", "machinenumber": "8002", "cmmversion": ""}, + {"label": "genspect", "pctype": "gea-shopfloor-genspect", "subtype": "", "hostname": "WJGEN01", "machinenumber": "8100", "cmmversion": ""}, + {"label": "heattreat", "pctype": "gea-shopfloor-heattreat", "subtype": "", "hostname": "WJHT01", "machinenumber": "8200", "cmmversion": ""}, + {"label": "waxtrace", "pctype": "gea-shopfloor-waxtrace", "subtype": "", "hostname": "WJWAX01", "machinenumber": "8300", "cmmversion": ""}, + {"label": "partmarker", "pctype": "gea-shopfloor-partmarker", "subtype": "", "hostname": "WJPM01", "machinenumber": "8400", "cmmversion": ""}, + {"label": "display", "pctype": "gea-shopfloor-display", "subtype": "", "hostname": "WJDISP01", "machinenumber": "", "cmmversion": ""}, + {"label": "legacy-standard-machine", "pctype": "Standard", "subtype": "Machine", "hostname": "WJLEG01", "machinenumber": "3202", "cmmversion": ""}, + {"label": "legacy-cmm-name", "pctype": "CMM", "subtype": "", "hostname": "WJLEG02", "machinenumber": "9102", "cmmversion": "2019"} +] diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py new file mode 100644 index 00000000..0732f32a --- /dev/null +++ b/plugins/geenforce/plugin.py @@ -0,0 +1,194 @@ +"""GE-Enforce manifest-store plugin. + +Owns the imaging-PC-type scopes and their install manifests as shopdb data +(see the ge-enforce-plugin proposal in the project wiki). This is the P0/P1 foundation: models, +the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and +client cutover come in later phases. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +import click +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db + +from .api import geenforce_bp +from .models import ( + ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname, + ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess, + ManifestPublishedVersion, ManifestPayload, ManifestEnforcementReport, + ManifestEnforcementResult, PcTypeAlias, +) +from .filters import ALIAS_GROUPS + +logger = logging.getLogger(__name__) + + +class GeEnforcePlugin(BasePlugin): + """Desired-state manifest store for the GE-Enforce client.""" + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + manifest_path = Path(__file__).parent / 'manifest.json' + if manifest_path.exists(): + with open(manifest_path, 'r') as handle: + return json.load(handle) + return {} + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest.get('name', 'geenforce'), + version=self._manifest.get('version', '0.1.0'), + description=self._manifest.get('description', 'GE-Enforce manifest store'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.7.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/geenforce'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return geenforce_bp + + def get_models(self) -> List[Type]: + return [ + ManifestScope, ManifestEntry, ManifestEntryPcType, + ManifestEntryHostname, ManifestEntryMachineNumber, + ManifestInUseCheck, ManifestInUseCheckProcess, + ManifestPublishedVersion, ManifestPayload, + ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias, + ] + + def get_permissions(self) -> List: + """RBAC permissions this plugin owns (edit vs ship are split).""" + return [ + ('geenforce.manage', 'Edit imaging PC types and manifest drafts', + 'geenforce'), + ('geenforce.publish', 'Publish, roll back, and export manifests', + 'geenforce'), + ('geenforce.fetch', 'Fetch published manifests (client service token)', + 'geenforce'), + ('geenforce.report', 'Report enforcement results (client service token)', + 'geenforce'), + ] + + def get_navigation_items(self) -> List[Dict]: + """Top-level sidebar section (GE-Enforce is a large operational surface, + not a mere setting). The tabbed shell hosts Manifests + Reports.""" + return [ + { + 'name': 'GE-Enforce', + 'icon': 'shield', + 'route': '/geenforce/manifests', + 'position': 46, + }, + ] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._seed_aliases() + db.session.commit() + logger.info("GE-Enforce plugin installed") + + def _seed_aliases(self) -> None: + """Seed pctypealiases from the engine lib's alias graph (idempotent).""" + for group_index, group in enumerate(ALIAS_GROUPS): + for aliasname in group: + exists = PcTypeAlias.query.filter_by( + aliasgroup=group_index, aliasname=aliasname).first() + if not exists: + db.session.add(PcTypeAlias( + aliasgroup=group_index, aliasname=aliasname)) + + def get_cli_commands(self) -> List: + """CLI: parity (Gate A), import-share, export-share, publish.""" + + @click.group('geenforce') + def geenforce_cli(): + """GE-Enforce manifest-store commands.""" + + @geenforce_cli.command('parity') + @click.option('--shareroot', required=True, + help='GE-Enforce share root (contains common/, ' + 'gea-shopfloor-*/).') + @click.option('--preinstall', default=None, + help='Optional path to a preinstall.json to include.') + def parity_cmd(shareroot, preinstall): + """Prove import+export is behaviorally lossless (Gate A). DB-free.""" + from .importer import discover_share, load_manifest_file + from .parity import run_parity, format_result + + manifests = list(discover_share(shareroot)) + if preinstall: + manifests.append( + ('preinstall', 'preinstall', load_manifest_file(preinstall))) + results, ok = run_parity(manifests) + for result in results: + click.echo(format_result(result)) + click.echo(f"RESULT: {'PASS' if ok else 'FAIL'} " + f"({len(results)} scopes)") + raise SystemExit(0 if ok else 1) + + @geenforce_cli.command('import-share') + @click.option('--shareroot', required=True) + @click.option('--preinstall', default=None) + @click.option('--scope', default=None, + help='Import only this scope name (else all).') + def import_share_cmd(shareroot, preinstall, scope): + """Import on-share manifests into draft rows (idempotent rebuild).""" + from flask import current_app + from .importer import discover_share, load_manifest_file + from .service import replace_scope_draft + + with current_app.app_context(): + sources = list(discover_share(shareroot)) + if preinstall: + sources.append(('preinstall', 'preinstall', + load_manifest_file(preinstall))) + count = 0 + for name, phase, manifest in sources: + if scope and name != scope: + continue + replace_scope_draft(name, phase, manifest) + count += 1 + db.session.commit() + click.echo(f"Imported {count} scope(s).") + + @geenforce_cli.command('publish') + @click.argument('scopename') + @click.option('--phase', default='runtime') + @click.option('--notes', default=None) + def publish_cmd(scopename, phase, notes): + """Freeze the current draft of a scope into a published snapshot.""" + from flask import current_app + from .service import publish_scope + + with current_app.app_context(): + version = publish_scope(scopename, phase, notes=notes) + db.session.commit() + click.echo(f"Published {scopename}/{phase} as v{version}.") + + @geenforce_cli.command('export-share') + @click.argument('scopename') + @click.option('--shareroot', required=True) + @click.option('--phase', default='runtime') + def export_share_cmd(scopename, shareroot, phase): + """Write a scope's published JSON to the share (with history backup).""" + from flask import current_app + from .service import export_scope_to_share + + with current_app.app_context(): + path = export_scope_to_share(scopename, phase, shareroot) + click.echo(f"Exported to {path}.") + + return [geenforce_cli] diff --git a/plugins/geenforce/serializer.py b/plugins/geenforce/serializer.py new file mode 100644 index 00000000..7fe40735 --- /dev/null +++ b/plugins/geenforce/serializer.py @@ -0,0 +1,133 @@ +"""Rebuild manifest JSON from DB rows, and canonicalize entries for parity. + +`scope_to_manifest` renders a ManifestScope back into the exact JSON structure +GE-Enforce consumes (Version / _comment / Applications, plus Site for +preinstall). `canonical_entry` reduces an Applications[] entry (from either the +on-share file or a rebuilt scope) to only the fields the engine reads, dropping +`_comment` and key order, so the parity harness can compare behavior not bytes. +""" + +import json + +# Scalar entry fields: model attribute -> manifest key. Emitted only when set, +# so a rebuilt entry has the same sparse key presence as the original. +_SCALAR_FIELDS = [ + ('name', 'Name'), + ('entrytype', 'Type'), + ('installer', 'Installer'), + ('installargs', 'InstallArgs'), + ('scriptpath', 'Script'), + ('scriptargs', 'Args'), + ('sourcepath', 'Source'), + ('destination', 'Destination'), + ('regpath', 'RegPath'), + ('regname', 'RegName'), + ('regtype', 'RegType'), + ('detectionmethod', 'DetectionMethod'), + ('detectionpath', 'DetectionPath'), + ('detectionname', 'DetectionName'), + ('detectionvalue', 'DetectionValue'), + ('detectionpattern', 'DetectionPattern'), + ('cmmversion', '_CmmVersion'), + ('logfile', 'LogFile'), + ('waittimeoutsec', 'WaitTimeoutSec'), + ('applymode', 'ApplyMode'), + ('updatewindow', 'UpdateWindow'), +] + +# Boolean flags emitted only when True (matches how preinstall.json carries them). +_BOOL_FLAGS = [ + ('preenrollment', 'PreEnrollment'), + ('killafterdetection', 'KillAfterDetection'), + ('pctypesstrict', 'PCTypesStrict'), +] + +# The complete set of engine-relevant keys the parity check compares. +ENGINE_KEYS = ( + [mk for _, mk in _SCALAR_FIELDS] + + [mk for _, mk in _BOOL_FLAGS] + + ['PCTypes', 'TargetHostnames', 'TargetMachineNumbers', 'RegValue', 'InUseCheck'] +) + + +def entry_to_dict(entry): + """Render a ManifestEntry model back into a manifest Applications[] entry.""" + result = {} + if entry.comment: + result['_comment'] = entry.comment + for attr, key in _SCALAR_FIELDS: + value = getattr(entry, attr) + if value is not None and value != '': + result[key] = value + # RegValue: stored as a raw JSON literal, reconstitute its real type. + if entry.regvalue is not None: + result['RegValue'] = json.loads(entry.regvalue) + # Multi-value filters (only when present). + if entry.pctypes: + result['PCTypes'] = [p.pctypevalue for p in entry.pctypes] + if entry.hostnames: + result['TargetHostnames'] = [h.hostnamepattern for h in entry.hostnames] + if entry.machinenumbers: + result['TargetMachineNumbers'] = [m.machinenumber + for m in entry.machinenumbers] + for attr, key in _BOOL_FLAGS: + if getattr(entry, attr): + result[key] = True + # InUseCheck (nested object + Processes[]). + if entry.inusecheck: + procs = [] + for proc in entry.inusecheck.processes: + pd = {'Name': proc.processname} + if proc.exepath is not None: + pd['ExePath'] = proc.exepath + if proc.gracefulclosetimeoutsec is not None: + pd['GracefulCloseTimeoutSec'] = proc.gracefulclosetimeoutsec + procs.append(pd) + result['InUseCheck'] = { + 'Behavior': entry.inusecheck.behavior, + 'Processes': procs, + } + return result + + +def scope_to_manifest(scope): + """Render a ManifestScope back into a full manifest.json structure.""" + manifest = {'Version': scope.manifestversion} + if scope.topcomment: + manifest['_comment'] = scope.topcomment + if scope.site: + manifest['Site'] = scope.site + manifest['Applications'] = [entry_to_dict(e) for e in scope.entries] + return manifest + + +def scope_to_json(scope, indent=2): + """The serialized JSON text a client/export would receive.""" + return json.dumps(scope_to_manifest(scope), indent=indent) + + +def canonical_entry(entry_dict): + """Reduce an Applications[] entry to engine-relevant fields for parity. + + Drops `_comment` and key order. Normalizes InUseCheck and the process list + so two entries that behave identically compare equal regardless of source. + """ + canon = {} + for key in ENGINE_KEYS: + if key == 'InUseCheck': + continue + if key in entry_dict and entry_dict[key] is not None and entry_dict[key] != '': + canon[key] = entry_dict[key] + inuse = entry_dict.get('InUseCheck') + if inuse: + procs = [] + for proc in inuse.get('Processes', []): + pd = {'Name': proc.get('Name')} + if proc.get('ExePath') is not None: + pd['ExePath'] = proc.get('ExePath') + if proc.get('GracefulCloseTimeoutSec') is not None: + pd['GracefulCloseTimeoutSec'] = proc.get('GracefulCloseTimeoutSec') + procs.append(pd) + canon['InUseCheck'] = {'Behavior': inuse.get('Behavior'), + 'Processes': procs} + return canon diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py new file mode 100644 index 00000000..f004bd2a --- /dev/null +++ b/plugins/geenforce/service.py @@ -0,0 +1,343 @@ +"""DB-touching operations for the manifest store. + +Kept out of the CLI and routes so both share one implementation: +- `replace_scope_draft` imports/re-imports a scope's DRAFT entries WITHOUT + touching its published-version history (re-import is idempotent + safe). +- `publish_scope` freezes the current draft into a new immutable snapshot. +- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle. +""" + +import hashlib +import os +import tempfile +from datetime import datetime, timezone + +from sqlalchemy import func + +from shopdb.api import db, Application + +from .models import ( + ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport, + ManifestEnforcementResult, ManifestPayload, +) +from .importer import build_entry +from .serializer import scope_to_json + + +def _utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _installed_app_model(): + """Lazily import the computers plugin's ComputerInstalledApp. + + The computers plugin is optional; importing it lazily (not at module load) + keeps geenforce usable when computers is absent or disabled. Mirrors + collector._computer_models. Returns the model class, or None if absent. + """ + try: + from plugins.computers.models import ComputerInstalledApp + return ComputerInstalledApp + except ImportError: + return None + + +def replace_scope_draft(scopename, phase, manifest): + """Create/refresh a scope and REPLACE its draft entries. Published versions + are left untouched. Returns the scope (uncommitted).""" + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + scope = ManifestScope(scopename=scopename, phase=phase) + db.session.add(scope) + scope.manifestversion = str(manifest.get('Version', '1.0')) + scope.topcomment = manifest.get('_comment') + scope.site = manifest.get('Site') + scope.iscommon = (scopename == 'common') + + for entry in list(scope.entries): + db.session.delete(entry) + db.session.flush() + + for i, entry_dict in enumerate(manifest.get('Applications') or []): + scope.entries.append(build_entry(entry_dict, i)) + return scope + + +def publish_scope(scopename, phase, notes=None, publishedby=None): + """Freeze the current draft into a new published snapshot. Returns the + version number (uncommitted).""" + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + raise ValueError(f'No scope {scopename}/{phase}') + + text = scope_to_json(scope) + maxversion = db.session.query( + func.max(ManifestPublishedVersion.versionnumber) + ).filter_by(scopeid=scope.scopeid).scalar() or 0 + + ManifestPublishedVersion.query.filter_by( + scopeid=scope.scopeid, iscurrent=True + ).update({'iscurrent': False}) + + db.session.add(ManifestPublishedVersion( + scopeid=scope.scopeid, + versionnumber=maxversion + 1, + manifestjson=text, + publishedat=_utcnow(), + publishedby=publishedby, + iscurrent=True, + notes=notes)) + return maxversion + 1 + + +def rollback_scope(scopename, phase, versionnumber): + """Make an older published version current again (uncommitted).""" + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + raise ValueError(f'No scope {scopename}/{phase}') + target = ManifestPublishedVersion.query.filter_by( + scopeid=scope.scopeid, versionnumber=versionnumber).first() + if not target: + raise ValueError(f'No version {versionnumber} for {scopename}') + ManifestPublishedVersion.query.filter_by( + scopeid=scope.scopeid, iscurrent=True + ).update({'iscurrent': False}) + target.iscurrent = True + return versionnumber + + +def record_enforcement_report(payload): + """Record one PC's enforcement cycle (observed state). Upserts the latest + report per (hostname, scopename, phase) and keeps prior ones as history. + + Payload (all but hostname optional): + hostname, scopename, phase, appliedversion, enforcerversion, lastcheckin, + counts {installed, skipped, failed, filtered}, + results [{name, action, selfhealed, exitcode, message}] + + Returns the new ManifestEnforcementReport (uncommitted). + """ + hostname = (payload.get('hostname') or '').strip() + if not hostname: + raise ValueError('hostname is required') + scopename = (payload.get('scopename') or '').strip() + phase = (payload.get('phase') or 'runtime').strip() + counts = payload.get('counts') or {} + results = payload.get('results') or [] + # Guard wrong JSON shapes: a bad type must be a clean 400, not a 500 from a + # later .get()/iteration (the route maps ValueError to 400). + if not isinstance(counts, dict): + raise ValueError('counts must be an object') + if not isinstance(results, list): + raise ValueError('results must be a list') + + failed = int(counts.get('failed', 0)) + installed = int(counts.get('installed', 0)) + # Derive status from EXPLICIT self-heal flags only, never the raw installed + # count: every healthy cycle runs Always/no-detection scripts (asset report, + # VNC firewall, EventSaver) that the engine counts as "installed", so keying + # self-heal off installed>0 would mark the common scope selfhealed forever + # and make 'ok' unreachable. A drift correction is one the client flags. + if failed > 0: + status = 'failed' + elif any(r.get('selfhealed') for r in results): + status = 'selfhealed' + else: + status = 'ok' + + # Demote the prior current report for this host+scope. Hostname match is + # case-insensitive to match the ilike read path - otherwise a PC reporting + # its name in different casing would leave two iscurrent rows. + ManifestEnforcementReport.query.filter( + func.lower(ManifestEnforcementReport.hostname) == hostname.lower(), + ManifestEnforcementReport.scopename == scopename, + ManifestEnforcementReport.phase == phase, + ManifestEnforcementReport.iscurrent == True + ).update({'iscurrent': False}, synchronize_session=False) + + report = ManifestEnforcementReport( + hostname=hostname, + scopename=scopename, + phase=phase, + appliedversion=payload.get('appliedversion'), + enforcerversion=payload.get('enforcerversion'), + installedcount=installed, + skippedcount=int(counts.get('skipped', 0)), + failedcount=failed, + filteredcount=int(counts.get('filtered', 0)), + status=status, + lastcheckin=_parse_dt(payload.get('lastcheckin')), + receivedat=_utcnow(), + iscurrent=True) + for item in results: + report.results.append(ManifestEnforcementResult( + entryname=item.get('name', ''), + action=item.get('action', ''), + # Explicit flag only; do NOT infer from action == 'installed' + # (Always/no-detection scripts install every cycle without being a + # drift correction). + selfhealed=bool(item.get('selfhealed', False)), + exitcode=item.get('exitcode'), + message=item.get('message'))) + db.session.add(report) + return report + + +def _parse_dt(value): + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace('Z', '+00:00')).replace( + tzinfo=None) + except (ValueError, TypeError): + return None + + +def compliance_for_scope(scope): + """Fleet-install coverage for a scope's app-linked entries. + + One row per ManifestEntry that carries a curated appid (unlinked entries + are skipped), ordered by sortorder. Counts come from the computers plugin's + ComputerInstalledApp (active rows only, one row per PC per app). Degrades + gracefully with null counts when the computers plugin is absent. + + Returns the response data dict (scopeid/scopename/phase/computersplugin/rows). + """ + linked = [e for e in sorted(scope.entries, key=lambda e: e.sortorder) + if e.appid is not None] + installedmodel = _installed_app_model() + computerspresent = installedmodel is not None + + # One grouped query for the whole scope's appid set (fleet is tiny). + installedbyapp = {} + if computerspresent and linked: + appids = {e.appid for e in linked} + counted = db.session.query( + installedmodel.appid, func.count(installedmodel.id) + ).filter( + installedmodel.isactive == True, + installedmodel.appid.in_(appids), + ).group_by(installedmodel.appid).all() + installedbyapp = {appid: count for appid, count in counted} + + rows = [] + for entry in linked: + app = db.session.get(Application, entry.appid) + appname = app.appname if app else None + expectedversion = (entry.detectionvalue + if entry.detectionmethod == 'FileVersion' else None) + + if not computerspresent: + installedcount = None + versionmatchcount = None + coveragenote = ('computers plugin not installed; install counts ' + 'unavailable') + else: + installedcount = installedbyapp.get(entry.appid, 0) + if installedcount == 0: + versionmatchcount = None if expectedversion is None else 0 + coveragenote = 'not installed on any collected PC' + elif expectedversion is None: + versionmatchcount = None + method = entry.detectionmethod or 'none' + coveragenote = (f'installed on {installedcount} PC(s); no ' + f'version target (detection is {method})') + else: + versionmatchcount = db.session.query( + func.count(installedmodel.id) + ).filter( + installedmodel.isactive == True, + installedmodel.appid == entry.appid, + installedmodel.installedversion == expectedversion, + ).scalar() or 0 + coveragenote = (f'{versionmatchcount} of {installedcount} ' + 'collected PCs on the expected version') + + rows.append({ + 'entryid': entry.entryid, + 'entryname': entry.name, + 'appid': entry.appid, + 'appname': appname, + 'expectedversion': expectedversion, + 'installedcount': installedcount, + 'versionmatchcount': versionmatchcount, + 'coveragenote': coveragenote, + }) + + return { + 'scopeid': scope.scopeid, + 'scopename': scope.scopename, + 'phase': scope.phase, + 'computersplugin': computerspresent, + 'rows': rows, + } + + +def store_inline_payload(entry, filename, contenttype, rawbytes): + """Replace the entry's inline payload with these bytes (uncommitted). + + Computes payloadsha256, upserts the single ManifestPayload row (the table + has no unique constraint on entryid, so the one-inline-payload-per-entry + rule is enforced here), and points the entry at it (payloadsource='inline'). + Returns the ManifestPayload. + """ + sha = hashlib.sha256(rawbytes).hexdigest() + ManifestPayload.query.filter_by(entryid=entry.entryid).delete() + db.session.flush() + payload = ManifestPayload( + entryid=entry.entryid, + filename=filename, + contenttype=contenttype, + payloadbytes=rawbytes, + payloadsha256=sha, + uploadedat=_utcnow()) + db.session.add(payload) + entry.payloadsource = 'inline' + entry.payloadref = filename + entry.payloadsha256 = sha + return payload + + +def export_scope_to_share(scopename, phase, shareroot): + """Write a scope's current published JSON to the share, backing up the old + file to _meta/history first. Returns the written path.""" + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + raise ValueError(f'No scope {scopename}/{phase}') + published = scope.publishedversions.filter_by(iscurrent=True).first() + if not published: + raise ValueError(f'{scopename} has no published version') + + if phase == 'preinstall': + target = os.path.join(shareroot, 'preinstall.json') + else: + target = os.path.join(shareroot, scopename, 'manifest.json') + os.makedirs(os.path.dirname(target), exist_ok=True) + + if os.path.isfile(target): + historydir = os.path.join(shareroot, '_meta', 'history') + os.makedirs(historydir, exist_ok=True) + stamp = _utcnow().strftime('%Y%m%d-%H%M%S') + with open(target) as src: + old = src.read() + with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst: + dst.write(old) + + # Atomic write: a partial/failed write must never leave the live on-share + # manifest (which every PC reads) truncated. Write a temp file in the same + # directory, then rename over the target. + targetdir = os.path.dirname(target) + fd, tmppath = tempfile.mkstemp(dir=targetdir, suffix='.tmp') + try: + with os.fdopen(fd, 'w') as handle: + handle.write(published.manifestjson) + os.replace(tmppath, target) + except Exception: + if os.path.exists(tmppath): + os.remove(tmppath) + raise + return target diff --git a/plugins/knowledgebase/__init__.py b/plugins/knowledgebase/__init__.py new file mode 100644 index 00000000..b4a73f0c --- /dev/null +++ b/plugins/knowledgebase/__init__.py @@ -0,0 +1,5 @@ +"""Knowledge Base plugin package.""" + +from .plugin import KnowledgeBasePlugin + +__all__ = ['KnowledgeBasePlugin'] diff --git a/plugins/knowledgebase/api/__init__.py b/plugins/knowledgebase/api/__init__.py new file mode 100644 index 00000000..afcaaca9 --- /dev/null +++ b/plugins/knowledgebase/api/__init__.py @@ -0,0 +1,5 @@ +"""Knowledge Base plugin API.""" + +from .routes import knowledgebase_bp + +__all__ = ['knowledgebase_bp'] diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py new file mode 100644 index 00000000..fcd35fc2 --- /dev/null +++ b/plugins/knowledgebase/api/routes.py @@ -0,0 +1,224 @@ +"""Knowledge Base API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + db, + Application, + success_response, + error_response, + paginated_response, + ErrorCodes, + get_pagination_params, + paginate_query, +) + +from ..models import KnowledgeBase + +from shopdb.api import require_permission, apply_import_timestamps + +knowledgebase_bp = Blueprint('knowledgebase', __name__) + + +@knowledgebase_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_articles(): + """List all knowledge base articles.""" + page, per_page = get_pagination_params(request) + + query = KnowledgeBase.query.filter_by(isactive=True) + + # Search + if search := request.args.get('search'): + query = query.filter( + db.or_( + KnowledgeBase.shortdescription.ilike(f'%{search}%'), + KnowledgeBase.keywords.ilike(f'%{search}%') + ) + ) + + # Filter by topic/application + if appid := request.args.get('appid'): + query = query.filter(KnowledgeBase.appid == int(appid)) + + # Exact-match natural-key lookups for idempotent import. linkurl is the + # stable natural key; shortdescription (the title) is offered as a fallback. + if exactlinkurl := request.args.get('linkurl'): + query = query.filter(KnowledgeBase.linkurl == exactlinkurl) + if exacttitle := request.args.get('shortdescription'): + query = query.filter(KnowledgeBase.shortdescription == exacttitle) + + # Sort options + sort = request.args.get('sort', 'clicks') + order = request.args.get('order', 'desc') + + if sort == 'clicks': + query = query.order_by( + KnowledgeBase.clicks.desc() if order == 'desc' else KnowledgeBase.clicks.asc(), + KnowledgeBase.lastupdated.desc() + ) + elif sort == 'topic': + query = query.join(Application).order_by( + Application.appname.desc() if order == 'desc' else Application.appname.asc() + ) + elif sort == 'description': + query = query.order_by( + KnowledgeBase.shortdescription.desc() if order == 'desc' else KnowledgeBase.shortdescription.asc() + ) + elif sort == 'lastupdated': + query = query.order_by( + KnowledgeBase.lastupdated.desc() if order == 'desc' else KnowledgeBase.lastupdated.asc() + ) + else: + query = query.order_by(KnowledgeBase.clicks.desc()) + + items, total = paginate_query(query, page, per_page) + data = [] + for article in items: + article_dict = article.to_dict() + if article.application: + article_dict['application'] = { + 'appid': article.application.appid, + 'appname': article.application.appname + } + else: + article_dict['application'] = None + data.append(article_dict) + + return paginated_response(data, page, per_page, total) + + +@knowledgebase_bp.route('/stats', methods=['GET']) +@jwt_required(optional=True) +def get_stats(): + """Get knowledge base statistics.""" + total_clicks = db.session.query( + db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0) + ).filter(KnowledgeBase.isactive == True).scalar() + + total_articles = KnowledgeBase.query.filter_by(isactive=True).count() + + return success_response({ + 'totalclicks': int(total_clicks), + 'totalarticles': total_articles + }) + + +@knowledgebase_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_article(link_id: int): + """Get a single knowledge base article.""" + article = db.session.get(KnowledgeBase, link_id) + + if not article or not article.isactive: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + data = article.to_dict() + if article.application: + data['application'] = { + 'appid': article.application.appid, + 'appname': article.application.appname + } + else: + data['application'] = None + + return success_response(data) + + +@knowledgebase_bp.route('//click', methods=['POST']) +@jwt_required(optional=True) +def track_click(link_id: int): + """Increment click counter and return the URL to redirect to.""" + article = db.session.get(KnowledgeBase, link_id) + + if not article or not article.isactive: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + article.increment_clicks() + db.session.commit() + + return success_response({ + 'linkurl': article.linkurl, + 'clicks': article.clicks + }) + + +@knowledgebase_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('kb.create') +def create_article(): + """Create a new knowledge base article.""" + data = request.get_json() + + if not data or not data.get('shortdescription'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'shortdescription is required') + + if not data.get('linkurl'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'linkurl is required') + + # Validate application if provided + if data.get('appid'): + app = db.session.get(Application, data['appid']) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + article = KnowledgeBase( + shortdescription=data['shortdescription'], + linkurl=data['linkurl'], + appid=data.get('appid'), + keywords=data.get('keywords'), + clicks=0 + ) + + db.session.add(article) + apply_import_timestamps(article, data) + db.session.commit() + + return success_response(article.to_dict(), message='Article created', http_code=201) + + +@knowledgebase_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('kb.edit') +def update_article(link_id: int): + """Update a knowledge base article.""" + article = db.session.get(KnowledgeBase, link_id) + + if not article: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate application if being changed + if 'appid' in data and data['appid']: + app = db.session.get(Application, data['appid']) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + fields = ['shortdescription', 'linkurl', 'appid', 'keywords', 'isactive'] + for key in fields: + if key in data: + setattr(article, key, data[key]) + + apply_import_timestamps(article, data) + db.session.commit() + return success_response(article.to_dict(), message='Article updated') + + +@knowledgebase_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('kb.delete') +def delete_article(link_id: int): + """Delete (deactivate) a knowledge base article.""" + article = db.session.get(KnowledgeBase, link_id) + + if not article: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + article.isactive = False + db.session.commit() + + return success_response(message='Article deleted') diff --git a/plugins/knowledgebase/manifest.json b/plugins/knowledgebase/manifest.json new file mode 100644 index 00000000..46dfb510 --- /dev/null +++ b/plugins/knowledgebase/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "knowledgebase", + "display_name": "Knowledge Base", + "version": "1.0.0", + "description": "Knowledge Base articles linking to external resources", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/knowledgebase", + "provides": { + "features": ["knowledgebase"] + } +} diff --git a/plugins/knowledgebase/migrations/env.py b/plugins/knowledgebase/migrations/env.py new file mode 100644 index 00000000..b230fff1 --- /dev/null +++ b/plugins/knowledgebase/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the knowledgebase plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_knowledgebase. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'knowledgebase' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/knowledgebase/migrations/script.py.mako b/plugins/knowledgebase/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/knowledgebase/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/knowledgebase/migrations/versions/0001_knowledgebase_anchor.py b/plugins/knowledgebase/migrations/versions/0001_knowledgebase_anchor.py new file mode 100644 index 00000000..143e3209 --- /dev/null +++ b/plugins/knowledgebase/migrations/versions/0001_knowledgebase_anchor.py @@ -0,0 +1,28 @@ +"""knowledgebase plugin anchor (ownership cutover). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_knowledgebase. From this +anchor forward, new knowledgebase schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'knowledgebase0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/knowledgebase/models/__init__.py b/plugins/knowledgebase/models/__init__.py new file mode 100644 index 00000000..761162a9 --- /dev/null +++ b/plugins/knowledgebase/models/__init__.py @@ -0,0 +1,5 @@ +"""Knowledge Base plugin models.""" + +from .knowledgebase import KnowledgeBase + +__all__ = ['KnowledgeBase'] diff --git a/plugins/knowledgebase/models/knowledgebase.py b/plugins/knowledgebase/models/knowledgebase.py new file mode 100644 index 00000000..b37b95e9 --- /dev/null +++ b/plugins/knowledgebase/models/knowledgebase.py @@ -0,0 +1,40 @@ +"""Knowledge Base model. + +Non-asset plugin model. The `knowledgebase` table lives in the core Alembic +chain (bundled-plugin schema is folded into core, ADR-004); this class just +maps it and is registered via the plugin's get_models hook. The appid FK +references the core applications table by name, which resolves at mapper config +time without importing the core model. +""" + +from datetime import datetime, timezone + +from shopdb.api import db, BaseModel + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class KnowledgeBase(BaseModel): + """Knowledge Base article linking to external resources.""" + __tablename__ = 'knowledgebase' + + linkid = db.Column(db.Integer, primary_key=True) + appid = db.Column(db.Integer, db.ForeignKey('applications.appid')) + shortdescription = db.Column(db.String(500), nullable=False) + linkurl = db.Column(db.String(2000)) + keywords = db.Column(db.String(500)) + clicks = db.Column(db.Integer, default=0) + lastupdated = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow) + + # Relationship to the core Application model (resolved by class name). + application = db.relationship('Application', backref=db.backref('knowledgebase_articles', lazy='dynamic')) + + def __repr__(self): + return f"" + + def increment_clicks(self): + """Increment click counter.""" + self.clicks = (self.clicks or 0) + 1 diff --git a/plugins/knowledgebase/plugin.py b/plugins/knowledgebase/plugin.py new file mode 100644 index 00000000..85fd73f6 --- /dev/null +++ b/plugins/knowledgebase/plugin.py @@ -0,0 +1,83 @@ +"""Knowledge Base plugin main class. + +Reference NON-asset plugin: contributes a model + blueprint + nav item but no +AssetType (it is not an asset). Mirrors the notifications plugin. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta + +from .models import KnowledgeBase +from .api import knowledgebase_bp + +logger = logging.getLogger(__name__) + + +class KnowledgeBasePlugin(BasePlugin): + """Knowledge Base plugin - articles linking to external resources.""" + + 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', 'knowledgebase'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Knowledge Base articles linking to external resources' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/knowledgebase'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return knowledgebase_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [KnowledgeBase] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Knowledge Base plugin initialized (v{self.meta.version})") + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Knowledge Base', + 'icon': 'book-open', + 'route': '/knowledgebase', + 'position': 35, + 'section': 'information', + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('kb.view', 'View knowledge base', 'knowledgebase'), + ('kb.create', 'Create KB articles', 'knowledgebase'), + ('kb.edit', 'Edit KB articles', 'knowledgebase'), + ('kb.delete', 'Delete KB articles', 'knowledgebase'), + ] diff --git a/plugins/machines/__init__.py b/plugins/machines/__init__.py new file mode 100644 index 00000000..a5c1cca1 --- /dev/null +++ b/plugins/machines/__init__.py @@ -0,0 +1,5 @@ +"""Machines plugin for ShopDB.""" + +from .plugin import MachinesPlugin + +__all__ = ['MachinesPlugin'] diff --git a/plugins/machines/api/__init__.py b/plugins/machines/api/__init__.py new file mode 100644 index 00000000..3dd6bc7e --- /dev/null +++ b/plugins/machines/api/__init__.py @@ -0,0 +1,5 @@ +"""Machines plugin API.""" + +from .routes import machines_bp + +__all__ = ['machines_bp'] diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py new file mode 100644 index 00000000..f309e32f --- /dev/null +++ b/plugins/machines/api/routes.py @@ -0,0 +1,550 @@ +"""Machines plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, Asset, AssetType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled + +from ..models import Machine, MachineType + +from shopdb.api import require_permission, apply_import_timestamps + +machines_bp = Blueprint('machines', __name__) + + +# ============================================================================= +# Machine Types +# ============================================================================= + +@machines_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_machine_types(): + """List all machine types.""" + page, per_page = get_pagination_params(request) + + query = MachineType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(MachineType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(MachineType.machinetype.ilike(f'%{search}%')) + + query = query.order_by(MachineType.machinetype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@machines_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_machine_type(type_id: int): + """Get a single machine type.""" + t = db.session.get(MachineType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@machines_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('machines.create') +def create_machine_type(): + """Create a new machine type.""" + data = request.get_json() + + if not data or not data.get('machinetype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required') + + existing = MachineType.query.filter_by(machinetype=data['machinetype']).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'icon', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing type') + return error_response( + ErrorCodes.CONFLICT, + f"Machine type '{data['machinetype']}' already exists", + http_code=409 + ) + + t = MachineType( + machinetype=data['machinetype'], + description=data.get('description'), + icon=data.get('icon'), color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Machine type created', http_code=201) + + +@machines_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('machines.edit') +def update_machine_type(type_id: int): + """Update a machine type.""" + t = db.session.get(MachineType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'machinetype' in data and data['machinetype'] != t.machinetype: + if MachineType.query.filter_by(machinetype=data['machinetype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Machine type '{data['machinetype']}' already exists", + http_code=409 + ) + + for key in ['machinetype', 'description', 'icon', 'color', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Machine type updated') + + +@machines_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('machines.delete') +def delete_machine_type(type_id: int): + """Delete a machine type. Refused if any asset still uses it.""" + t = db.session.get(MachineType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Machine type not found', http_code=404) + inuse = Machine.query.filter_by(machinetypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} asset(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Machine type deleted') + + +# ============================================================================= +# Machine CRUD +# ============================================================================= + +@machines_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_machines(): + """ + List all machines with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number or name + - type_id: Filter by machine type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + """ + page, per_page = get_pagination_params(request) + + # Join Machine with Asset + query = db.session.query(Machine).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + # Machine type filter + if type_id := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Machine.machinetypeid == int(type_id)) + + # Vendor filter + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): + query = query.filter(Machine.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'assetnumber') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = Asset.assetnumber + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + # Dualpath single-machine collapse: hide the secondary bay so a dual-bay + # pair lists (and paginates) as one machine. Excluding before pagination + # keeps totals honest. Gated on the site setting (default on). + collapse = None + if dualpath_single_machine_enabled(): + collapse = resolve_dualpath_pairs() + if collapse.secondaryassetids: + query = query.filter(Asset.assetid.notin_(collapse.secondaryassetids)) + + items, total = paginate_query(query, page, per_page) + + # Resolve partner machineids for this page's primaries in one query. + machineidbyasset = {} + if collapse: + partnerassetids = [ + collapse.partnerbyasset[m.assetid]['assetid'] + for m in items if m.assetid in collapse.partnerbyasset + ] + if partnerassetids: + for partner in Machine.query.filter( + Machine.assetid.in_(partnerassetids)).all(): + machineidbyasset[partner.assetid] = partner.machineid + + # Build response with both asset and machine data + data = [] + for mach in items: + item = mach.asset.to_dict() if mach.asset else {} + item['machine'] = mach.to_dict() + # annotate the visible bay with its hidden partner (for '2007 / 2008') + partner = collapse.partnerbyasset.get(mach.assetid) if collapse else None + item['dualpathpartner'] = { + 'assetid': partner['assetid'], + 'machineid': machineidbyasset.get(partner['assetid']), + 'assetnumber': partner['assetnumber'], + } if partner else None + data.append(item) + + return paginated_response(data, page, per_page, total) + + +def _dualpath_partner_for(assetid): + """Resolve this machine's Dualpath sibling, or None. Always evaluated + (independent of the collapse toggle) so the detail-page banner shows even + when a site lists both bays. Returns {assetid, machineid, assetnumber}.""" + partner = resolve_dualpath_pairs().partnerbyasset.get(assetid) + if not partner: + return None + partnermach = Machine.query.filter_by(assetid=partner['assetid']).first() + return { + 'assetid': partner['assetid'], + 'machineid': partnermach.machineid if partnermach else None, + 'assetnumber': partner['assetnumber'], + } + + +@machines_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_machine(machine_id: int): + """Get a single machine item with full details.""" + mach = db.session.get(Machine, machine_id) + + if not mach: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + result = mach.asset.to_dict() if mach.asset else {} + result['machine'] = mach.to_dict() + result['dualpathpartner'] = _dualpath_partner_for(mach.assetid) + + return success_response(result) + + +@machines_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_machine_by_asset(asset_id: int): + """Get machine data by asset ID.""" + mach = Machine.query.filter_by(assetid=asset_id).first() + + if not mach: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine for asset {asset_id} not found', + http_code=404 + ) + + result = mach.asset.to_dict() if mach.asset else {} + result['machine'] = mach.to_dict() + result['dualpathpartner'] = _dualpath_partner_for(mach.assetid) + + return success_response(result) + + +@machines_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('machines.create') +def create_machine(): + """ + Create new machine (creates both Asset and Machine records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - machinetypeid, vendorid, modelnumberid + - requiresmanualconfig, islocationonly + - mapx, mapy, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Get machine asset type + machine_type = AssetType.query.filter_by(assettype='machine').first() + if not machine_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Machine asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + serialnumber=data.get('serialnumber'), + assettypeid=machine_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the machine extension + mach = Machine( + assetid=asset.assetid, + machinetypeid=data.get('machinetypeid'), + vendorid=data.get('vendorid'), + modelnumberid=data.get('modelnumberid'), + requiresmanualconfig=data.get('requiresmanualconfig', False), + islocationonly=data.get('islocationonly', False), + lastmaintenancedate=data.get('lastmaintenancedate'), + nextmaintenancedate=data.get('nextmaintenancedate'), + maintenanceintervaldays=data.get('maintenanceintervaldays'), + controllervendorid=data.get('controllervendorid'), + controllermodelid=data.get('controllermodelid') + ) + + db.session.add(mach) + db.session.flush() + + # Preserve legacy timestamps in import mode (no-op otherwise) + apply_import_timestamps(asset, data) + + # Audit log + AuditLog.log('created', 'Machine', entityid=mach.machineid, + entityname=data['assetnumber']) + + db.session.commit() + + result = asset.to_dict() + result['machine'] = mach.to_dict() + + return success_response(result, message='Machine created', http_code=201) + + +@machines_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('machines.edit') +def update_machine(machine_id: int): + """Update machine (both Asset and Machine records).""" + mach = db.session.get(Machine, machine_id) + + if not mach: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = mach.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Track changes for audit log + changes = {} + + # Update asset fields + 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) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(asset, key, data[key]) + + # Update machine fields + machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid', + 'requiresmanualconfig', 'islocationonly', + 'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays', + 'controllervendorid', 'controllermodelid'] + for key in machine_fields: + if key in data: + old_val = getattr(mach, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(mach, key, data[key]) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'Machine', entityid=mach.machineid, + entityname=asset.assetnumber, changes=changes) + + apply_import_timestamps(asset, data) + db.session.commit() + + result = asset.to_dict() + result['machine'] = mach.to_dict() + + return success_response(result, message='Machine updated') + + +@machines_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('machines.delete') +def delete_machine(machine_id: int): + """Delete (soft delete) machine.""" + mach = db.session.get(Machine, machine_id) + + if not mach: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + # Soft delete the asset (machine extension will stay linked) + mach.asset.isactive = False + + # Audit log + AuditLog.log('deleted', 'Machine', entityid=mach.machineid, + entityname=mach.asset.assetnumber) + + db.session.commit() + + return success_response(message='Machine deleted') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@machines_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get machine dashboard summary data.""" + # Dualpath single-machine collapse: hide the secondary bay from counts so a + # dual-bay pair counts once. Gated on the site setting (default on). + secondaryassetids = set() + if dualpath_single_machine_enabled(): + secondaryassetids = resolve_dualpath_pairs().secondaryassetids + + # Total active machine count + total_query = db.session.query(Machine).join(Asset).filter( + Asset.isactive == True + ) + if secondaryassetids: + total_query = total_query.filter(Asset.assetid.notin_(secondaryassetids)) + total = total_query.count() + + # Count by machine type + by_type_query = db.session.query( + MachineType.machinetype, + db.func.count(Machine.machineid) + ).join(Machine, Machine.machinetypeid == MachineType.machinetypeid + ).join(Asset, Asset.assetid == Machine.assetid + ).filter(Asset.isactive == True) + if secondaryassetids: + by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids)) + by_type = by_type_query.group_by(MachineType.machinetype).all() + + # Count by status + from shopdb.api import AssetStatus + by_status = db.session.query( + AssetStatus.status, + db.func.count(Machine.machineid) + ).join(Asset, Asset.assetid == Machine.assetid + ).join(AssetStatus, AssetStatus.statusid == Asset.statusid + ).filter(Asset.isactive == True + ).group_by(AssetStatus.status + ).all() + + return success_response({ + 'total': total, + 'bytype': [{'type': t, 'count': c} for t, c in by_type], + 'bystatus': [{'status': s, 'count': c} for s, c in by_status] + }) diff --git a/plugins/machines/manifest.json b/plugins/machines/manifest.json new file mode 100644 index 00000000..58542b34 --- /dev/null +++ b/plugins/machines/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "machines", + "version": "1.0.0", + "description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/machines", + "provides": { + "asset_type": "machine", + "features": [ + "machine_tracking", + "maintenance_scheduling", + "vendor_management", + "model_catalog" + ] + }, + "settings": { + "enable_maintenance_alerts": true, + "maintenance_alert_days": 30 + } +} diff --git a/plugins/machines/migrations/env.py b/plugins/machines/migrations/env.py new file mode 100644 index 00000000..37e15a7c --- /dev/null +++ b/plugins/machines/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the machines plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_machines. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'machines' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/machines/migrations/script.py.mako b/plugins/machines/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/machines/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/machines/migrations/versions/0001_machines_anchor.py b/plugins/machines/migrations/versions/0001_machines_anchor.py new file mode 100644 index 00000000..977560e6 --- /dev/null +++ b/plugins/machines/migrations/versions/0001_machines_anchor.py @@ -0,0 +1,32 @@ +"""machines plugin anchor (ownership cutover; authored as equipment). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_machines. From this +anchor forward, new machines schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. + +The revision id keeps its original 'equipment0001anchor' string: ids are +arbitrary and existing installs already carry it in their version table +(which the core chain renames to alembic_version_machines). +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'equipment0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/machines/migrations/versions/0002_rename_from_equipment.py b/plugins/machines/migrations/versions/0002_rename_from_equipment.py new file mode 100644 index 00000000..3ae3066e --- /dev/null +++ b/plugins/machines/migrations/versions/0002_rename_from_equipment.py @@ -0,0 +1,168 @@ +"""Rename equipment tables to machines + +Plugin half of the approved equipment -> machines rename (the core half is +migrations/versions/7d17_machines_rename.py): + + equipment -> machines (equipmentid -> machineid, + equipmenttypeid -> machinetypeid) + equipmenttypes -> machinetypes (equipmenttypeid -> machinetypeid, + equipmenttype -> machinetype) + +Runs on BOTH upgraded installs and fresh installs: the core baseline creates +these tables under their old names (frozen DDL), so this revision always does +the physical rename. Idempotent: skips when the tables already carry the new +names (e.g. test DBs built by db.create_all() from current models). Refuses +to run while the core machinetypes table still exists - `flask db upgrade` +(which renames it to modeltypes) must land first. + +Revision ID: machines0002rename +Revises: equipment0001anchor +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'machines0002rename' +down_revision = 'equipment0001anchor' +branch_labels = None +depends_on = None + + +def _fk_names(insp, table, referred_table): + # find FK constraint names on table pointing at referred_table + return [fk['name'] for fk in insp.get_foreign_keys(table) + if fk.get('referred_table') == referred_table and fk.get('name')] + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + + if 'equipment' not in tables and 'equipmenttypes' not in tables: + # already renamed, or fresh schema built straight from current models + return + + if 'machinetypes' in tables: + # core chain still owns the machinetypes name (not yet renamed to + # modeltypes) - renaming equipmenttypes onto it would collide + raise RuntimeError( + "Core migration 7d17_machines_rename has not run: the legacy " + "machinetypes table still exists. Run `flask db upgrade` before " + "`flask plugin upgrade-all`." + ) + + if bind.dialect.name == 'mysql': + _upgrade_mysql(bind, insp) + else: + _upgrade_generic(insp) + + +def _upgrade_mysql(bind, insp): + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + + # snapshot index names before the renames invalidate the table name + machine_indexes = {ix['name'] for ix in insp.get_indexes('equipment')} + + # drop the equipment -> equipmenttypes FK before renaming the parent cols + for name in _fk_names(insp, 'equipment', 'equipmenttypes'): + bind.exec_driver_sql(f"ALTER TABLE equipment DROP FOREIGN KEY {name}") + + bind.exec_driver_sql("RENAME TABLE equipmenttypes TO machinetypes") + bind.exec_driver_sql("RENAME TABLE equipment TO machines") + + bind.exec_driver_sql( + "ALTER TABLE machinetypes " + "CHANGE equipmenttypeid machinetypeid INT NOT NULL AUTO_INCREMENT, " + "CHANGE equipmenttype machinetype VARCHAR(100) NOT NULL, " + "DROP KEY equipmenttype, " + "ADD UNIQUE KEY machinetype (machinetype)" + ) + + parts = [ + "CHANGE equipmentid machineid INT NOT NULL AUTO_INCREMENT", + "CHANGE equipmenttypeid machinetypeid INT NULL", + ] + # rename the model-declared index names to match the Machine model + if 'idx_equipment_type' in machine_indexes: + parts += ["DROP KEY idx_equipment_type", + "ADD KEY idx_machine_type (machinetypeid)"] + if 'idx_equipment_vendor' in machine_indexes: + parts += ["DROP KEY idx_equipment_vendor", + "ADD KEY idx_machine_vendor (vendorid)"] + if 'ix_equipment_assetid' in machine_indexes: + parts += ["DROP KEY ix_equipment_assetid", + "ADD UNIQUE KEY ix_machines_assetid (assetid)"] + parts += [ + "ADD CONSTRAINT fk_machines_machinetypeid " + "FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)" + ] + bind.exec_driver_sql("ALTER TABLE machines " + ", ".join(parts)) + + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") + + +def _upgrade_generic(insp): + # SQLite (and anything else): batch rename via ALTER ... RENAME + op.rename_table('equipmenttypes', 'machinetypes') + op.rename_table('equipment', 'machines') + with op.batch_alter_table('machinetypes') as batch_op: + batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid', + existing_type=sa.Integer(), existing_nullable=False) + batch_op.alter_column('equipmenttype', new_column_name='machinetype', + existing_type=sa.String(100), + existing_nullable=False) + with op.batch_alter_table('machines') as batch_op: + batch_op.alter_column('equipmentid', new_column_name='machineid', + existing_type=sa.Integer(), existing_nullable=False) + batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid', + existing_type=sa.Integer(), existing_nullable=True) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + tables = set(insp.get_table_names()) + + if 'machines' not in tables and 'machinetypes' not in tables: + return + + if bind.dialect.name != 'mysql': + raise NotImplementedError( + "downgrade implemented for MySQL only (dev/prod dialect)") + + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + machine_indexes = {ix['name'] for ix in insp.get_indexes('machines')} + for name in _fk_names(insp, 'machines', 'machinetypes'): + bind.exec_driver_sql(f"ALTER TABLE machines DROP FOREIGN KEY {name}") + + bind.exec_driver_sql("RENAME TABLE machinetypes TO equipmenttypes") + bind.exec_driver_sql("RENAME TABLE machines TO equipment") + + bind.exec_driver_sql( + "ALTER TABLE equipmenttypes " + "CHANGE machinetypeid equipmenttypeid INT NOT NULL AUTO_INCREMENT, " + "CHANGE machinetype equipmenttype VARCHAR(100) NOT NULL, " + "DROP KEY machinetype, " + "ADD UNIQUE KEY equipmenttype (equipmenttype)" + ) + + parts = [ + "CHANGE machineid equipmentid INT NOT NULL AUTO_INCREMENT", + "CHANGE machinetypeid equipmenttypeid INT NULL", + ] + if 'idx_machine_type' in machine_indexes: + parts += ["DROP KEY idx_machine_type", + "ADD KEY idx_equipment_type (equipmenttypeid)"] + if 'idx_machine_vendor' in machine_indexes: + parts += ["DROP KEY idx_machine_vendor", + "ADD KEY idx_equipment_vendor (vendorid)"] + if 'ix_machines_assetid' in machine_indexes: + parts += ["DROP KEY ix_machines_assetid", + "ADD UNIQUE KEY ix_equipment_assetid (assetid)"] + parts += [ + "ADD CONSTRAINT equipment_ibfk_2 " + "FOREIGN KEY (equipmenttypeid) REFERENCES equipmenttypes (equipmenttypeid)" + ] + bind.exec_driver_sql("ALTER TABLE equipment " + ", ".join(parts)) + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") diff --git a/plugins/machines/models/__init__.py b/plugins/machines/models/__init__.py new file mode 100644 index 00000000..5712ecad --- /dev/null +++ b/plugins/machines/models/__init__.py @@ -0,0 +1,8 @@ +"""Machines plugin models.""" + +from .machine import Machine, MachineType + +__all__ = [ + 'Machine', + 'MachineType', +] diff --git a/plugins/machines/models/machine.py b/plugins/machines/models/machine.py new file mode 100644 index 00000000..d73090c5 --- /dev/null +++ b/plugins/machines/models/machine.py @@ -0,0 +1,133 @@ +"""Machines plugin models.""" + +from shopdb.api import db, BaseModel + + +class MachineType(BaseModel): + """ + Machine type classification. + + Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc. + """ + __tablename__ = 'machinetypes' + + machinetypeid = db.Column(db.Integer, primary_key=True) + machinetype = 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') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class Machine(BaseModel): + """ + Machine-specific extension data. + + Links to core Asset table via assetid. + Stores machine-specific fields like type, model, vendor, etc. + """ + __tablename__ = 'machines' + + machineid = 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 + ) + + # Machine classification + machinetypeid = db.Column( + db.Integer, + db.ForeignKey('machinetypes.machinetypeid'), + 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 + ) + + # Machine-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 machine)' + ) + + # 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('machine', uselist=False, lazy='joined') + ) + machinetype = db.relationship('MachineType', backref='machines') + vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='machine_items') + model = db.relationship('Model', foreign_keys=[modelnumberid], backref='machine_items') + controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='machine_controllers') + controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='machine_controller_models') + + __table_args__ = ( + db.Index('idx_machine_type', 'machinetypeid'), + db.Index('idx_machine_vendor', 'vendorid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.machinetype: + result['machinetypename'] = self.machinetype.machinetype + if self.vendor: + result['vendorname'] = self.vendor.vendor + if self.model: + result['modelname'] = self.model.modelnumber + if self.model.imageurl: + result['imageurl'] = self.model.imageurl + + # Add controller info + if self.controllervendor: + result['controllervendorname'] = self.controllervendor.vendor + if self.controllermodel: + result['controllermodelname'] = self.controllermodel.modelnumber + + return result diff --git a/plugins/machines/plugin.py b/plugins/machines/plugin.py new file mode 100644 index 00000000..177d15e4 --- /dev/null +++ b/plugins/machines/plugin.py @@ -0,0 +1,228 @@ +"""Machines 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 Machine, MachineType +from .api import machines_bp + +logger = logging.getLogger(__name__) + + +class MachinesPlugin(BasePlugin): + """ + Machines plugin - manages manufacturing machine assets. + + Machines include CNCs, CMMs, lathes, grinders, EDMs, part markers, etc. + Uses the new Asset architecture with Machine 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', 'machines'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Machine 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/machines'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return machines_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Machine, MachineType] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Machines 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_machine_types() + logger.info("Machines plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure machine asset type exists.""" + existing = AssetType.query.filter_by(assettype='machine').first() + if not existing: + at = AssetType( + assettype='machine', + pluginname='machines', + tablename='machines', + description='Manufacturing machines (CNCs, CMMs, lathes, etc.)', + icon='cog' + ) + db.session.add(at) + logger.debug("Created asset type: machine") + 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_machine_types(self) -> None: + """Ensure basic machine types exist.""" + machine_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 machine', 'marker'), + ('Mill', 'Milling machine', 'mill'), + ('Press', 'Press machine', 'press'), + ('Robot', 'Industrial robot', 'robot'), + ('Other', 'Other machine type', 'cog'), + ] + + for name, description, icon in machine_types: + existing = MachineType.query.filter_by(machinetype=name).first() + if not existing: + mt = MachineType( + machinetype=name, + description=description, + icon=icon + ) + 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("Machines plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('machines') + def machinescli(): + """Machines plugin commands.""" + pass + + @machinescli.command('list-types') + def list_types(): + """List all machine types.""" + from flask import current_app + + with current_app.app_context(): + types = MachineType.query.filter_by(isactive=True).all() + if not types: + click.echo('No machine types found.') + return + + click.echo('Machine Types:') + for t in types: + click.echo(f" [{t.machinetypeid}] {t.machinetype}") + + @machinescli.command('stats') + def stats(): + """Show machine statistics.""" + from flask import current_app + from shopdb.api import Asset + + with current_app.app_context(): + total = db.session.query(Machine).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active machines: {total}") + + # By type + by_type = db.session.query( + MachineType.machinetype, + db.func.count(Machine.machineid) + ).join(Machine, Machine.machinetypeid == MachineType.machinetypeid + ).join(Asset, Asset.assetid == Machine.assetid + ).filter(Asset.isactive == True + ).group_by(MachineType.machinetype + ).all() + + if by_type: + click.echo("\nBy Type:") + for t, c in by_type: + click.echo(f" {t}: {c}") + + return [machinescli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Machine Status', + 'component': 'MachineStatusWidget', + 'endpoint': '/api/machines/dashboard/summary', + 'size': 'medium', + 'position': 5, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Machines', + 'icon': 'cog', + 'route': '/machines', + 'position': 10, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('machines.view', 'View machines', 'machines'), + ('machines.create', 'Create machines', 'machines'), + ('machines.edit', 'Edit machines', 'machines'), + ('machines.delete', 'Delete machines', 'machines'), + ] diff --git a/plugins/measuringtools/__init__.py b/plugins/measuringtools/__init__.py new file mode 100644 index 00000000..a4d9da23 --- /dev/null +++ b/plugins/measuringtools/__init__.py @@ -0,0 +1,5 @@ +"""Measuring-tools plugin package.""" + +from .plugin import MeasuringToolsPlugin + +__all__ = ['MeasuringToolsPlugin'] diff --git a/plugins/measuringtools/api/__init__.py b/plugins/measuringtools/api/__init__.py new file mode 100644 index 00000000..58ffce94 --- /dev/null +++ b/plugins/measuringtools/api/__init__.py @@ -0,0 +1,5 @@ +"""Measuring-tools plugin API blueprint.""" + +from .routes import measuringtools_bp + +__all__ = ['measuringtools_bp'] diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py new file mode 100644 index 00000000..36ad8214 --- /dev/null +++ b/plugins/measuringtools/api/routes.py @@ -0,0 +1,395 @@ +"""Measuring-tools plugin API. + +Two resources: the measuringtooltypes lookup (CRUD with an in-use delete guard) +and the measuring tools themselves (an Asset core row plus a measuringtools +extension row, written in one payload). Reads are jwt-optional per the app +convention; writes require the measuringtools.* permissions. + +Calibration status is derived at read time in the model (see +models.derive_status), never stored, so the report and list badges are always +current. +""" + +from datetime import date, datetime + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + db, Asset, AssetType, AuditLog, + success_response, error_response, paginated_response, ErrorCodes, + get_pagination_params, paginate_query, + require_permission, apply_import_timestamps, +) + +from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS + +measuringtools_bp = Blueprint('measuringtools', __name__) + + +def _parse_date(value): + """Accept 'YYYY-MM-DD' (or None/empty) -> date or None.""" + if not value: + return None + try: + return datetime.strptime(str(value)[:10], '%Y-%m-%d').date() + except (ValueError, TypeError): + return None + + +def _merged(tool, today=None): + """Asset core dict with the measuringtools extension nested under 'measuringtool'.""" + result = tool.asset.to_dict() if tool.asset else {} + result['measuringtool'] = tool.to_dict(today) + return result + + +# ============================================================================= +# Measuring-tool types +# ============================================================================= + +@measuringtools_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_types(): + """List measuring-tool types.""" + page, per_page = get_pagination_params(request) + query = MeasuringToolType.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(MeasuringToolType.isactive == True) + if search := request.args.get('search'): + query = query.filter(MeasuringToolType.name.ilike(f'%{search}%')) + query = query.order_by(MeasuringToolType.name) + items, total = paginate_query(query, page, per_page) + return paginated_response([t.to_dict() for t in items], page, per_page, total) + + +@measuringtools_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_type(type_id: int): + """Get one measuring-tool type.""" + tool_type = db.session.get(MeasuringToolType, type_id) + if not tool_type: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring-tool type {type_id} not found', http_code=404) + return success_response(tool_type.to_dict()) + + +@measuringtools_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('measuringtools.create') +def create_type(): + """Create a measuring-tool type (reactivates a soft-deleted same-named one).""" + data = request.get_json() or {} + name = (data.get('name') or '').strip() + if not name: + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + existing = MeasuringToolType.query.filter_by(name=name).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing type') + return error_response(ErrorCodes.CONFLICT, + f"Measuring-tool type '{name}' already exists", http_code=409) + + tool_type = MeasuringToolType( + name=name, + description=data.get('description'), + color=data.get('color'), + ) + db.session.add(tool_type) + db.session.commit() + return success_response(tool_type.to_dict(), message='Measuring-tool type created', + http_code=201) + + +@measuringtools_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('measuringtools.edit') +def update_type(type_id: int): + """Update a measuring-tool type.""" + tool_type = db.session.get(MeasuringToolType, type_id) + if not tool_type: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring-tool type {type_id} not found', http_code=404) + data = request.get_json() or {} + if 'name' in data and data['name'] != tool_type.name: + if MeasuringToolType.query.filter_by(name=data['name']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Measuring-tool type '{data['name']}' already exists", + http_code=409) + for key in ('name', 'description', 'color', 'isactive'): + if key in data: + setattr(tool_type, key, data[key]) + db.session.commit() + return success_response(tool_type.to_dict(), message='Measuring-tool type updated') + + +@measuringtools_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('measuringtools.delete') +def delete_type(type_id: int): + """Delete a measuring-tool type. Refused if any tool still uses it.""" + tool_type = db.session.get(MeasuringToolType, type_id) + if not tool_type: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring-tool type {type_id} not found', http_code=404) + inuse = MeasuringTool.query.filter_by(measuringtooltypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f'Cannot delete: {inuse} tool(s) still use this type', + http_code=409) + db.session.delete(tool_type) + db.session.commit() + return success_response(message='Measuring-tool type deleted') + + +# ============================================================================= +# Measuring tools +# ============================================================================= + +@measuringtools_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_tools(): + """List measuring tools with filters + pagination. + + Query params: typeid, locationid, statusid, calibrationstatus, search, + active, page, perpage. + """ + page, per_page = get_pagination_params(request) + query = db.session.query(MeasuringTool).join(Asset) + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + if search := request.args.get('search'): + query = query.filter(db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + )) + if type_id := request.args.get('typeid', type=int): + query = query.filter(MeasuringTool.measuringtooltypeid == type_id) + if location_id := request.args.get('locationid', type=int): + query = query.filter(Asset.locationid == location_id) + if status_id := request.args.get('statusid', type=int): + query = query.filter(Asset.statusid == status_id) + + query = query.order_by(Asset.assetnumber) + items, total = paginate_query(query, page, per_page) + + today = date.today() + data = [_merged(tool, today) for tool in items] + + # Calibration status is derived, so it cannot be a SQL filter - apply it + # to the built rows. Filtering post-pagination is acceptable here because + # the dataset is a gage lab's worth of tools, not a fleet. + calibrationstatus = request.args.get('calibrationstatus') + if calibrationstatus: + data = [d for d in data + if d['measuringtool']['calibrationstatus'] == calibrationstatus] + + return paginated_response(data, page, per_page, total) + + +@measuringtools_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_tool(tool_id: int): + """Get one measuring tool (asset core merged with the extension).""" + tool = db.session.get(MeasuringTool, tool_id) + if not tool: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring tool {tool_id} not found', http_code=404) + return success_response(_merged(tool)) + + +@measuringtools_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_tool_by_asset(asset_id: int): + """Get a measuring tool by its core asset id.""" + tool = MeasuringTool.query.filter_by(assetid=asset_id).first() + if not tool: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring tool for asset {asset_id} not found', http_code=404) + return success_response(_merged(tool)) + + +def _asset_type_id(): + """Resolve the seeded 'measuring_tool' asset-type id, or None.""" + asset_type = AssetType.query.filter_by(assettype='measuring_tool').first() + return asset_type.assettypeid if asset_type else None + + +@measuringtools_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('measuringtools.create') +def create_tool(): + """Create a measuring tool: one Asset core row + one extension row.""" + data = request.get_json() or {} + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409) + + assettypeid = _asset_type_id() + if not assettypeid: + return error_response(ErrorCodes.INTERNAL_ERROR, + 'measuring_tool asset type not found. Plugin may not be ' + 'properly installed.', http_code=500) + + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + serialnumber=data.get('serialnumber'), + assettypeid=assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes'), + ) + db.session.add(asset) + db.session.flush() # assign assetid + + tool = MeasuringTool( + assetid=asset.assetid, + measuringtooltypeid=data.get('measuringtooltypeid'), + calibrationintervaldays=data.get('calibrationintervaldays'), + lastcalibrationdate=_parse_date(data.get('lastcalibrationdate')), + nextcalibrationdate=_parse_date(data.get('nextcalibrationdate')), + calibrationprovider=(data.get('calibrationprovider') or '').strip() or None, + notes=(data.get('notes') or '').strip() or None, + ) + db.session.add(tool) + db.session.flush() + + apply_import_timestamps(asset, data) + AuditLog.log('created', 'MeasuringTool', entityid=tool.measuringtoolid, + entityname=asset.assetnumber) + db.session.commit() + return success_response(_merged(tool), message='Measuring tool created', http_code=201) + + +# Asset core fields writable through this plugin's write path. +_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference', + 'maintenancereference', 'serialnumber', + 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', + 'notes', 'isactive') +# Extension fields with plain assignment (dates handled separately). +_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays', + 'calibrationprovider', 'notes') + + +@measuringtools_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('measuringtools.edit') +def update_tool(tool_id: int): + """Update a measuring tool (asset core fields + extension in one payload).""" + tool = db.session.get(MeasuringTool, tool_id) + if not tool: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring tool {tool_id} not found', http_code=404) + data = request.get_json() or {} + asset = tool.asset + + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409) + + changes = {} + for key in _ASSET_FIELDS: + if key in data: + if getattr(asset, key) != data[key]: + changes[key] = {'old': getattr(asset, key), 'new': data[key]} + setattr(asset, key, data[key]) + for key in _TOOL_FIELDS: + if key in data: + if getattr(tool, key) != data[key]: + changes[key] = {'old': getattr(tool, key), 'new': data[key]} + setattr(tool, key, data[key]) + for key in ('lastcalibrationdate', 'nextcalibrationdate'): + if key in data: + setattr(tool, key, _parse_date(data[key])) + + if changes: + AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid, + entityname=asset.assetnumber, changes=changes) + apply_import_timestamps(asset, data) + db.session.commit() + return success_response(_merged(tool), message='Measuring tool updated') + + +@measuringtools_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('measuringtools.delete') +def delete_tool(tool_id: int): + """Soft delete a measuring tool (deactivates its asset).""" + tool = db.session.get(MeasuringTool, tool_id) + if not tool: + return error_response(ErrorCodes.NOT_FOUND, + f'Measuring tool {tool_id} not found', http_code=404) + tool.asset.isactive = False + AuditLog.log('deleted', 'MeasuringTool', entityid=tool.measuringtoolid, + entityname=tool.asset.assetnumber) + db.session.commit() + return success_response(message='Measuring tool deleted') + + +# ============================================================================= +# Map overlay (ADR-010 calibration-due badge) +# ============================================================================= + +@measuringtools_bp.route('/map-overlay', methods=['GET']) +@jwt_required(optional=True) +def map_overlay(): + """Calibration-status overlay for active measuring tools. + + The map places markers itself from the assets feed; this overlay only + supplies the per-asset calibration decoration. Consumers join by assetid, + so no map coordinates are returned. Status is derived at read time. + """ + today = date.today() + query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True) + data = [] + for tool in query.all(): + status = derive_status(tool.nextcalibrationdate, today) + data.append({ + 'assetid': tool.assetid, + 'calibrationstatus': status, + 'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']), + }) + return success_response(data) + + +# ============================================================================= +# Calibration report (for the Reports hub) +# ============================================================================= + +@measuringtools_bp.route('/report/calibration', methods=['GET']) +@jwt_required(optional=True) +def calibration_report(): + """Counts + lists bucketed by derived calibration status.""" + today = date.today() + buckets = {'overdue': [], 'duesoon': [], 'current': [], 'unknown': []} + query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True) + for tool in query.all(): + buckets.setdefault(derive_status(tool.nextcalibrationdate, today), []).append( + _merged(tool, today)) + return success_response({ + 'counts': {key: len(value) for key, value in buckets.items()}, + 'buckets': buckets, + 'statuscolors': STATUS_COLORS, + }) diff --git a/plugins/measuringtools/manifest.json b/plugins/measuringtools/manifest.json new file mode 100644 index 00000000..f61c03e3 --- /dev/null +++ b/plugins/measuringtools/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "measuringtools", + "display_name": "Measuring Tools", + "version": "1.0.0", + "description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.6.0,<1.0.0", + "api_prefix": "/api/measuringtools", + "default_enabled": false, + "provides": { + "features": ["measuring-tools", "calibration-tracking"] + } +} diff --git a/plugins/measuringtools/migrations/env.py b/plugins/measuringtools/migrations/env.py new file mode 100644 index 00000000..709e2ef6 --- /dev/null +++ b/plugins/measuringtools/migrations/env.py @@ -0,0 +1,16 @@ +"""Alembic environment for the measuring-tools plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which filters +the metadata to this plugin's tables and drives Alembic against the per-plugin +version table alembic_version_measuringtools. See ADR-008 for the ownership +model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin +is NEW: its 0001 baseline really CREATES its tables, because the core chain +never built them. +""" +import os + +os.environ['PLUGIN_NAME'] = 'measuringtools' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/measuringtools/migrations/script.py.mako b/plugins/measuringtools/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/measuringtools/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py b/plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py new file mode 100644 index 00000000..8b102464 --- /dev/null +++ b/plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py @@ -0,0 +1,69 @@ +"""measuringtools plugin baseline (real create). + +This plugin was built AFTER the ADR-008 ownership cutover, so unlike the ten +bundled plugins whose 0001 is a stamp-only no-op anchor, this baseline actually +CREATES the plugin's tables. The core Alembic chain never knew about +measuringtooltypes / measuringtools, so this per-plugin chain is their sole +authoritative creator. It runs from `flask plugin install measuringtools` +(and `flask plugin upgrade-all`) after `flask db upgrade` builds the core schema. + +Note on create_plugin_tables: the shared helper in +shopdb.plugins.alembic_template builds a per-plugin MetaData filtered to only the +plugin's own tables, which means a foreign key to a core table (assets) cannot +be resolved at CreateTable-compile time (NoReferencedTableError). Since these +tables reference assets.assetid, the baseline emits explicit Alembic ops (the +same shape Alembic autogenerate produces) instead. Tables inherit the +connection's default charset, so on a utf8mb4 database they are utf8mb4, matching +how the core chain creates its tables. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'measuringtools0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'measuringtooltypes', + sa.Column('measuringtooltypeid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=20), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('measuringtooltypeid'), + sa.UniqueConstraint('name'), + ) + op.create_table( + 'measuringtools', + sa.Column('measuringtoolid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('measuringtooltypeid', sa.Integer(), nullable=True), + sa.Column('calibrationintervaldays', sa.Integer(), nullable=True), + sa.Column('lastcalibrationdate', sa.Date(), nullable=True), + sa.Column('nextcalibrationdate', sa.Date(), nullable=True), + sa.Column('calibrationprovider', sa.String(length=150), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['measuringtooltypeid'], + ['measuringtooltypes.measuringtooltypeid']), + sa.PrimaryKeyConstraint('measuringtoolid'), + sa.UniqueConstraint('assetid'), + ) + op.create_index('idx_measuringtool_type', 'measuringtools', + ['measuringtooltypeid']) + + +def downgrade(): + op.drop_index('idx_measuringtool_type', table_name='measuringtools') + op.drop_table('measuringtools') + op.drop_table('measuringtooltypes') diff --git a/plugins/measuringtools/models/__init__.py b/plugins/measuringtools/models/__init__.py new file mode 100644 index 00000000..f1f9f8de --- /dev/null +++ b/plugins/measuringtools/models/__init__.py @@ -0,0 +1,17 @@ +"""Measuring-tools plugin models.""" + +from .measuringtool import ( + MeasuringTool, + MeasuringToolType, + derive_status, + STATUS_COLORS, + DUESOON_WINDOW_DAYS, +) + +__all__ = [ + 'MeasuringTool', + 'MeasuringToolType', + 'derive_status', + 'STATUS_COLORS', + 'DUESOON_WINDOW_DAYS', +] diff --git a/plugins/measuringtools/models/measuringtool.py b/plugins/measuringtools/models/measuringtool.py new file mode 100644 index 00000000..78d9464e --- /dev/null +++ b/plugins/measuringtools/models/measuringtool.py @@ -0,0 +1,133 @@ +"""Measuring-tools models. + +Measuring tools are gage-lab instruments (calipers, micrometers, thread gages, +bore gages, height gages, Genspect heads, ...) that measure parts, as opposed +to machines that make parts (see ADR-005). Each tool is a core Asset plus a +one-to-one measuringtools extension row. + +The lifecycle a measuring tool cares about is CALIBRATION, not maintenance: +an interval, a last date, and a next date. Calibration STATUS is DERIVED from +nextcalibrationdate at read time (see derive_status), never stored, so it is +always current no matter how long since the last write. This mirrors the +warranty plugin's derive-at-read pattern. +""" + +from datetime import date, timedelta + +from shopdb.api import db, BaseModel + +# Window before nextcalibrationdate where a tool counts as "due soon". +DUESOON_WINDOW_DAYS = 30 + +# Derived status -> display color (hex). Reused by the frontend status badge. +STATUS_COLORS = { + 'overdue': '#F44336', + 'duesoon': '#FF9800', + 'current': '#4CAF50', + 'unknown': '#9E9E9E', +} + + +def derive_status(nextcalibrationdate, today=None): + """Calibration status from a next-calibration date. Never stored. + + overdue - the next date is in the past + duesoon - the next date is within DUESOON_WINDOW_DAYS from today + current - the next date is further out than the window + unknown - no next date recorded + """ + if not nextcalibrationdate: + return 'unknown' + today = today or date.today() + if nextcalibrationdate < today: + return 'overdue' + if nextcalibrationdate <= today + timedelta(days=DUESOON_WINDOW_DAYS): + return 'duesoon' + return 'current' + + +class MeasuringToolType(BaseModel): + """Measuring-tool classification (Caliper, Micrometer, Thread Gage, ...). + + Site-managed lookup with a display color for badges and map markers, + the same shape as the machine/computer/printer type tables. + """ + __tablename__ = 'measuringtooltypes' + + measuringtooltypeid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class MeasuringTool(BaseModel): + """Measuring-tool extension data, one-to-one with a core Asset. + + Identity lives on the Asset (assetnumber = gage tag, serialnumber = vendor + serial, gaugelabreference identifier). This row carries only the metrology + domain fields: the tool type and the calibration lifecycle. + """ + __tablename__ = 'measuringtools' + + measuringtoolid = db.Column(db.Integer, primary_key=True) + + # Link to the core asset (one extension row per asset). + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True, + ) + + measuringtooltypeid = db.Column( + db.Integer, + db.ForeignKey('measuringtooltypes.measuringtooltypeid'), + nullable=True, + ) + + # Calibration lifecycle. Status is DERIVED from nextcalibrationdate, so + # none of these columns store a status. + calibrationintervaldays = db.Column(db.Integer, nullable=True) + lastcalibrationdate = db.Column(db.Date, nullable=True) + nextcalibrationdate = db.Column(db.Date, nullable=True) + calibrationprovider = db.Column(db.String(150), nullable=True) + + notes = db.Column(db.Text, nullable=True) + + asset = db.relationship( + 'Asset', + backref=db.backref('measuringtool', uselist=False, lazy='joined'), + ) + measuringtooltype = db.relationship('MeasuringToolType', backref='tools') + + __table_args__ = ( + db.Index('idx_measuringtool_type', 'measuringtooltypeid'), + ) + + def __repr__(self): + return f"" + + def calibrationstatus(self, today=None): + return derive_status(self.nextcalibrationdate, today) + + def to_dict(self, today=None): + """Extension dict with the type name and derived calibration status.""" + result = super().to_dict() + # BaseModel.to_dict only isoformats datetime, not plain date columns. + # Emit calibration dates as 'YYYY-MM-DD' so the frontend parses them + # the same way it parses warranty dates. + for field in ('lastcalibrationdate', 'nextcalibrationdate'): + value = getattr(self, field) + result[field] = value.isoformat() if value else None + if self.measuringtooltype: + result['measuringtooltypename'] = self.measuringtooltype.name + result['measuringtooltypecolor'] = self.measuringtooltype.color + status = self.calibrationstatus(today) + result['calibrationstatus'] = status + result['calibrationstatuscolor'] = STATUS_COLORS.get( + status, STATUS_COLORS['unknown']) + return result diff --git a/plugins/measuringtools/plugin.py b/plugins/measuringtools/plugin.py new file mode 100644 index 00000000..5a08e3fb --- /dev/null +++ b/plugins/measuringtools/plugin.py @@ -0,0 +1,179 @@ +"""Measuring-tools plugin main class. + +The first plugin built on the matured framework scaffold (ADR-005). It owns two +tables (measuringtooltypes, measuringtools), a blueprint under /api/measuringtools, +a sidebar entry, and a calibration report card. It seeds its own asset type and +a set of starter tool types on install. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, AssetType + +from .api import measuringtools_bp +from .models import MeasuringTool, MeasuringToolType + +logger = logging.getLogger(__name__) + +# Starter tool types seeded on install: (name, description, color). +# Colors are drawn from the shared frontend PALETTE (utils/colorStyle.js). +STARTER_TYPES = [ + ('Caliper', 'Vernier / digital caliper', '#14abef'), + ('Micrometer', 'Outside / inside / depth micrometer', '#2dce89'), + ('Thread Gage', 'Go / no-go thread gage', '#fb6340'), + ('Bore Gage', 'Bore / hole diameter gage', '#7934f3'), + ('Height Gage', 'Height / vertical measuring gage', '#ffc107'), + ('Indicator', 'Dial / test indicator', '#11cdef'), + ('Gage Block Set', 'Reference gage block set', '#e83e8c'), + ('Other', 'Other measuring tool', '#6c757d'), +] + + +class MeasuringToolsPlugin(BasePlugin): + """Metrology and inspection instruments (calibration lifecycle).""" + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + 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 PluginMeta( + name=self._manifest.get('name', 'measuringtools'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', 'Metrology and inspection instruments'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.6.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/measuringtools'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return measuringtools_bp + + def get_models(self) -> List[Type]: + return [MeasuringTool, MeasuringToolType] + + def get_navigation_items(self) -> List[Dict]: + return [ + { + 'name': 'Measuring Tools', + 'icon': 'ruler', + 'route': '/measuringtools', + 'position': 22, + }, + ] + + def get_reports(self) -> List[Dict]: + return [ + { + 'id': 'calibration', + 'name': 'Calibration Due', + 'description': 'Measuring tools bucketed by calibration status', + 'category': 'compliance', + 'route': '/reports/calibration', + }, + ] + + def get_config_schema(self) -> List[Dict]: + # No external credentials or endpoints: calibration is tracked by hand, + # so the setup wizard shows nothing to configure. Documented in the + # plugin guide as the intentional empty-schema case. + return [] + + def get_settings_cards(self) -> List[Dict]: + # ADR-010 pilot. Contributes the Measuring Tools settings card that used + # to be hardcoded in the core settingsNav.js catalog. + return [ + { + 'group': 'Measuring Tools', + 'to': '/settings/measuringtooltypes', + 'icon': 'ruler', + 'title': 'Measuring Tool Types', + 'description': 'Manage measuring-tool subtypes (caliper, ' + 'micrometer, thread gage...) + map colors', + 'position': 22, + }, + ] + + def get_asset_presentation(self) -> List[Dict]: + # ADR-010 pilot. Tells core how to render + link the measuring_tool + # asset type in global-search rows and cross-links. + # The consumer only substitutes {assetid} (search/cross-link rows carry + # the core asset id, not the extension id), so link through the by-asset + # resolver route rather than the id-keyed detail route. + return [ + { + 'assettype': 'measuring_tool', + 'icon': 'ruler', + 'label': 'Measuring Tool', + 'route': '/measuringtools/by-asset/{assetid}', + }, + ] + + def get_map_overlays(self) -> List[Dict]: + # ADR-010 pilot. Declares a calibration-due badge overlay for the + # shop-floor map; the map fetches the endpoint to decorate markers. + return [ + { + 'id': 'calibration-due', + 'label': 'Calibration due', + 'endpoint': '/api/measuringtools/map-overlay', + 'style': 'badge', + 'legend': True, + }, + ] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f"Measuring-tools plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._ensure_asset_type() + self._ensure_starter_types() + db.session.commit() + logger.info("Measuring-tools plugin installed") + + def _ensure_asset_type(self) -> None: + existing = AssetType.query.filter_by(assettype='measuring_tool').first() + if not existing: + db.session.add(AssetType( + assettype='measuring_tool', + pluginname='measuringtools', + tablename='measuringtools', + description='Metrology and inspection instruments (gauges, ' + 'calipers, thread gages, bore gages, ...)', + icon='ruler', + )) + logger.debug("Created asset type: measuring_tool") + db.session.commit() + + def _ensure_starter_types(self) -> None: + for name, description, color in STARTER_TYPES: + if not MeasuringToolType.query.filter_by(name=name).first(): + db.session.add(MeasuringToolType( + name=name, description=description, color=color)) + logger.debug(f"Created measuring-tool type: {name}") + db.session.commit() + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('measuringtools.view', 'View measuring tools', 'measuringtools'), + ('measuringtools.create', 'Create measuring tools', 'measuringtools'), + ('measuringtools.edit', 'Edit measuring tools', 'measuringtools'), + ('measuringtools.delete', 'Delete measuring tools', 'measuringtools'), + ] diff --git a/plugins/network/__init__.py b/plugins/network/__init__.py new file mode 100644 index 00000000..8fd878c9 --- /dev/null +++ b/plugins/network/__init__.py @@ -0,0 +1,5 @@ +"""Network plugin for ShopDB.""" + +from .plugin import NetworkPlugin + +__all__ = ['NetworkPlugin'] diff --git a/plugins/network/api/__init__.py b/plugins/network/api/__init__.py new file mode 100644 index 00000000..aad02b4b --- /dev/null +++ b/plugins/network/api/__init__.py @@ -0,0 +1,5 @@ +"""Network plugin API.""" + +from .routes import network_bp + +__all__ = ['network_bp'] diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py new file mode 100644 index 00000000..751ae659 --- /dev/null +++ b/plugins/network/api/routes.py @@ -0,0 +1,1029 @@ +"""Network plugin API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, Asset, AssetType, Vendor, Communication, CommunicationType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query + +from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN + +from shopdb.api import require_permission, apply_import_timestamps + +network_bp = Blueprint('network', __name__) + + +def _primary_ip(assetid): + """The asset's primary IP address string, or None. Mirrors the printer + route: a device's IP lives in a Communication row, not on the extension.""" + comm = (Communication.query.filter_by(assetid=assetid, isprimary=True).first() + or Communication.query.filter_by(assetid=assetid).first()) + return comm.ipaddress if comm else None + + +def _upsert_primary_ip(asset, ip): + """Create/update/clear the asset's primary IP Communication from an + ipaddress string. No-op if the IP CommunicationType is not seeded.""" + ip = (ip or '').strip() + if not ip: + return + comm = Communication.query.filter_by(assetid=asset.assetid, isprimary=True).first() + if comm: + comm.ipaddress = ip + return + 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)) + + +# ============================================================================= +# Network Device Types +# ============================================================================= + +@network_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_network_device_types(): + """List all network device types.""" + page, per_page = get_pagination_params(request) + + query = NetworkDeviceType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(NetworkDeviceType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(NetworkDeviceType.networkdevicetype.ilike(f'%{search}%')) + + query = query.order_by(NetworkDeviceType.networkdevicetype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_network_device_type(type_id: int): + """Get a single network device type.""" + t = db.session.get(NetworkDeviceType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@network_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('network.create') +def create_network_device_type(): + """Create a new network device type.""" + data = request.get_json() + + if not data or not data.get('networkdevicetype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'networkdevicetype is required') + + existing = NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'icon', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing type') + return error_response( + ErrorCodes.CONFLICT, + f"Network device type '{data['networkdevicetype']}' already exists", + http_code=409 + ) + + t = NetworkDeviceType( + networkdevicetype=data['networkdevicetype'], + description=data.get('description'), + icon=data.get('icon'), color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Network device type created', http_code=201) + + +@network_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('network.edit') +def update_network_device_type(type_id: int): + """Update a network device type.""" + t = db.session.get(NetworkDeviceType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'networkdevicetype' in data and data['networkdevicetype'] != t.networkdevicetype: + if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Network device type '{data['networkdevicetype']}' already exists", + http_code=409 + ) + + for key in ['networkdevicetype', 'description', 'icon', 'color', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Network device type updated') + + +@network_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('network.delete') +def delete_network_device_type(type_id: int): + """Delete a network device type. Refused if any device still uses it.""" + t = db.session.get(NetworkDeviceType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Network device type not found', http_code=404) + inuse = NetworkDevice.query.filter_by(networkdevicetypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} device(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Network device type deleted') + + +# ============================================================================= +# Network Devices CRUD +# ============================================================================= + +@network_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_network_devices(): + """ + List all network devices with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by network device type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - poe: Filter by PoE capability (true/false) + - managed: Filter by managed status (true/false) + """ + page, per_page = get_pagination_params(request) + + # Join NetworkDevice with Asset + query = db.session.query(NetworkDevice).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + NetworkDevice.hostname.ilike(f'%{search}%') + ) + ) + + # Type filter + 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('vendorid', request.args.get('vendor_id')): + query = query.filter(NetworkDevice.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # PoE filter + if poe := request.args.get('poe'): + query = query.filter(NetworkDevice.ispoe == (poe.lower() == 'true')) + + # Managed filter + if managed := request.args.get('managed'): + query = query.filter(NetworkDevice.ismanaged == (managed.lower() == 'true')) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = NetworkDevice.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = NetworkDevice.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and network device data + data = [] + for netdev in items: + item = netdev.asset.to_dict() if netdev.asset else {} + item['networkdevice'] = netdev.to_dict() + item['ipaddress'] = _primary_ip(netdev.assetid) + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_network_device(device_id: int): + """Get a single network device with full details.""" + netdev = db.session.get(NetworkDevice, device_id) + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with ID {device_id} not found', + http_code=404 + ) + + result = netdev.asset.to_dict() if netdev.asset else {} + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(netdev.assetid) + + return success_response(result) + + +@network_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_network_device_by_asset(asset_id: int): + """Get network device data by asset ID.""" + netdev = NetworkDevice.query.filter_by(assetid=asset_id).first() + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device for asset {asset_id} not found', + http_code=404 + ) + + result = netdev.asset.to_dict() if netdev.asset else {} + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(netdev.assetid) + + return success_response(result) + + +@network_bp.route('/by-hostname/', methods=['GET']) +@jwt_required(optional=True) +def get_network_device_by_hostname(hostname: str): + """Get network device by hostname.""" + netdev = NetworkDevice.query.filter_by(hostname=hostname).first() + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with hostname {hostname} not found', + http_code=404 + ) + + result = netdev.asset.to_dict() if netdev.asset else {} + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(netdev.assetid) + + return success_response(result) + + +@network_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('network.create') +def create_network_device(): + """ + Create new network device (creates both Asset and NetworkDevice records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - networkdevicetypeid, vendorid, hostname + - firmwareversion, portcount, ispoe, ismanaged, rackunit + - mapx, mapy, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for duplicate hostname + if data.get('hostname'): + if NetworkDevice.query.filter_by(hostname=data['hostname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Network device with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Get network device asset type + network_type = AssetType.query.filter_by(assettype='network_device').first() + if not network_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Network device asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + assettypeid=network_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the network device extension + netdev = NetworkDevice( + assetid=asset.assetid, + networkdevicetypeid=data.get('networkdevicetypeid'), + vendorid=data.get('vendorid'), + hostname=data.get('hostname'), + firmwareversion=data.get('firmwareversion'), + portcount=data.get('portcount'), + ispoe=data.get('ispoe', False), + ismanaged=data.get('ismanaged', False), + rackunit=data.get('rackunit') + ) + + db.session.add(netdev) + db.session.flush() + + # Attach the primary IP as a Communication when supplied (a device's IP is + # not a NetworkDevice column). Mirrors the printer create route. + if data.get('ipaddress'): + _upsert_primary_ip(asset, data['ipaddress']) + + # Preserve legacy timestamps in import mode (no-op otherwise) + apply_import_timestamps(asset, data) + + # Audit log + AuditLog.log('created', 'NetworkDevice', entityid=netdev.networkdeviceid, + entityname=data.get('hostname') or data['assetnumber']) + + db.session.commit() + + result = asset.to_dict() + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(asset.assetid) + + return success_response(result, message='Network device created', http_code=201) + + +@network_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('network.edit') +def update_network_device(device_id: int): + """Update network device (both Asset and NetworkDevice records).""" + netdev = db.session.get(NetworkDevice, device_id) + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with ID {device_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = netdev.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Check for conflicting hostname + if 'hostname' in data and data['hostname'] != netdev.hostname: + existing = NetworkDevice.query.filter_by(hostname=data['hostname']).first() + if existing and existing.networkdeviceid != device_id: + return error_response( + ErrorCodes.CONFLICT, + f"Network device with hostname '{data['hostname']}' already exists", + http_code=409 + ) + + # Track changes for audit log + changes = {} + + # Update asset fields + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] + for key in asset_fields: + if key in data: + old_val = getattr(asset, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(asset, key, data[key]) + + # Update network device fields + netdev_fields = ['networkdevicetypeid', 'vendorid', 'hostname', + 'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit'] + for key in netdev_fields: + if key in data: + old_val = getattr(netdev, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(netdev, key, data[key]) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'NetworkDevice', entityid=netdev.networkdeviceid, + entityname=netdev.hostname or asset.assetnumber, changes=changes) + + # Upsert the primary IP Communication when an ipaddress is supplied. + if 'ipaddress' in data: + _upsert_primary_ip(asset, data['ipaddress']) + + apply_import_timestamps(asset, data) + db.session.commit() + + result = asset.to_dict() + result['networkdevice'] = netdev.to_dict() + result['ipaddress'] = _primary_ip(asset.assetid) + + return success_response(result, message='Network device updated') + + +@network_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('network.delete') +def delete_network_device(device_id: int): + """Delete (soft delete) network device.""" + netdev = db.session.get(NetworkDevice, device_id) + + if not netdev: + return error_response( + ErrorCodes.NOT_FOUND, + f'Network device with ID {device_id} not found', + http_code=404 + ) + + # Soft delete the asset + netdev.asset.isactive = False + + # Audit log + AuditLog.log('deleted', 'NetworkDevice', entityid=netdev.networkdeviceid, + entityname=netdev.hostname or netdev.asset.assetnumber) + + db.session.commit() + + return success_response(message='Network device deleted') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@network_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get network device dashboard summary data.""" + # Total active network devices + total = db.session.query(NetworkDevice).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by device 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() + + # Count by vendor + by_vendor = db.session.query( + Vendor.vendor, + db.func.count(NetworkDevice.networkdeviceid) + ).join(NetworkDevice, NetworkDevice.vendorid == Vendor.vendorid + ).join(Asset, Asset.assetid == NetworkDevice.assetid + ).filter(Asset.isactive == True + ).group_by(Vendor.vendor + ).all() + + # Count PoE vs non-PoE + poe_count = db.session.query(NetworkDevice).join(Asset).filter( + Asset.isactive == True, + NetworkDevice.ispoe == True + ).count() + + return success_response({ + 'total': total, + 'bytype': [{'type': t, 'count': c} for t, c in by_type], + 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], + 'poe': poe_count, + 'nonpoe': total - poe_count + }) + + +# ============================================================================= +# VLANs +# ============================================================================= + +@network_bp.route('/vlans', methods=['GET']) +@jwt_required(optional=True) +def list_vlans(): + """List all VLANs with filtering and pagination.""" + page, per_page = get_pagination_params(request) + + query = VLAN.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(VLAN.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + VLAN.name.ilike(f'%{search}%'), + VLAN.description.ilike(f'%{search}%'), + db.cast(VLAN.vlannumber, db.String).ilike(f'%{search}%') + ) + ) + + # Type filter + if vlan_type := request.args.get('type'): + query = query.filter(VLAN.vlantype == vlan_type) + + query = query.order_by(VLAN.vlannumber) + + items, total = paginate_query(query, page, per_page) + data = [v.to_dict() for v in items] + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/vlans/', methods=['GET']) +@jwt_required(optional=True) +def get_vlan(vlan_id: int): + """Get a single VLAN with its subnets.""" + vlan = db.session.get(VLAN, vlan_id) + + if not vlan: + return error_response( + ErrorCodes.NOT_FOUND, + f'VLAN with ID {vlan_id} not found', + http_code=404 + ) + + result = vlan.to_dict() + # Include associated subnets + result['subnets'] = [s.to_dict() for s in vlan.subnets.filter_by(isactive=True).all()] + + return success_response(result) + + +@network_bp.route('/vlans', methods=['POST']) +@jwt_required() +@require_permission('network.create') +def create_vlan(): + """Create a new VLAN.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('vlannumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'vlannumber is required') + if not data.get('name'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + # Check for duplicate VLAN number + if VLAN.query.filter_by(vlannumber=data['vlannumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"VLAN {data['vlannumber']} already exists", + http_code=409 + ) + + vlan = VLAN( + vlannumber=data['vlannumber'], + name=data['name'], + description=data.get('description'), + vlantype=data.get('vlantype') + ) + + db.session.add(vlan) + db.session.flush() + + # Audit log + AuditLog.log('created', 'VLAN', entityid=vlan.vlanid, + entityname=f"VLAN {vlan.vlannumber} - {vlan.name}") + + db.session.commit() + + return success_response(vlan.to_dict(), message='VLAN created', http_code=201) + + +@network_bp.route('/vlans/', methods=['PUT']) +@jwt_required() +@require_permission('network.edit') +def update_vlan(vlan_id: int): + """Update a VLAN.""" + vlan = db.session.get(VLAN, vlan_id) + + if not vlan: + return error_response( + ErrorCodes.NOT_FOUND, + f'VLAN with ID {vlan_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting VLAN number + if 'vlannumber' in data and data['vlannumber'] != vlan.vlannumber: + if VLAN.query.filter_by(vlannumber=data['vlannumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"VLAN {data['vlannumber']} already exists", + http_code=409 + ) + + # Track changes for audit log + changes = {} + for key in ['vlannumber', 'name', 'description', 'vlantype', 'isactive']: + if key in data: + old_val = getattr(vlan, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(vlan, key, data[key]) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'VLAN', entityid=vlan.vlanid, + entityname=f"VLAN {vlan.vlannumber} - {vlan.name}", changes=changes) + + db.session.commit() + return success_response(vlan.to_dict(), message='VLAN updated') + + +@network_bp.route('/vlans/', methods=['DELETE']) +@jwt_required() +@require_permission('network.delete') +def delete_vlan(vlan_id: int): + """Delete (soft delete) a VLAN.""" + vlan = db.session.get(VLAN, vlan_id) + + if not vlan: + return error_response( + ErrorCodes.NOT_FOUND, + f'VLAN with ID {vlan_id} not found', + http_code=404 + ) + + # Check if VLAN has associated subnets + if vlan.subnets.filter_by(isactive=True).count() > 0: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Cannot delete VLAN with associated subnets', + http_code=400 + ) + + vlan.isactive = False + + # Audit log + AuditLog.log('deleted', 'VLAN', entityid=vlan.vlanid, + entityname=f"VLAN {vlan.vlannumber} - {vlan.name}") + + db.session.commit() + + return success_response(message='VLAN deleted') + + +# ============================================================================= +# Subnets +# ============================================================================= + +@network_bp.route('/subnets', methods=['GET']) +@jwt_required(optional=True) +def list_subnets(): + """List all subnets with filtering and pagination.""" + page, per_page = get_pagination_params(request) + + query = Subnet.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Subnet.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Subnet.cidr.ilike(f'%{search}%'), + Subnet.name.ilike(f'%{search}%'), + Subnet.description.ilike(f'%{search}%') + ) + ) + + # VLAN filter + if vlan_id := request.args.get('vlanid'): + query = query.filter(Subnet.vlanid == int(vlan_id)) + + # Location filter + if location_id := request.args.get('locationid'): + query = query.filter(Subnet.locationid == int(location_id)) + + # Type filter + if subnet_type := request.args.get('type'): + query = query.filter(Subnet.subnettype == subnet_type) + + query = query.order_by(Subnet.cidr) + + items, total = paginate_query(query, page, per_page) + data = [s.to_dict() for s in items] + + return paginated_response(data, page, per_page, total) + + +@network_bp.route('/subnets/', methods=['GET']) +@jwt_required(optional=True) +def get_subnet(subnet_id: int): + """Get a single subnet plus the network devices whose primary IP falls in + its CIDR range.""" + subnet = db.session.get(Subnet, subnet_id) + + if not subnet: + return error_response( + ErrorCodes.NOT_FOUND, + f'Subnet with ID {subnet_id} not found', + http_code=404 + ) + + data = subnet.to_dict() + data['devices'] = _devices_in_subnet(subnet.cidr) + return success_response(data) + + +def _devices_in_subnet(cidr): + """ANY asset (PC, printer, network device, ...) whose primary IP falls in + cidr. A subnet is cross-type, so this matches on the core Communication + + Asset tables, not just network devices. Matching is in Python because the IP + lives in a Communication row, not a column.""" + import ipaddress + from shopdb.api import Asset, AssetType, Communication + try: + net = ipaddress.ip_network(cidr, strict=False) + except (ValueError, TypeError): + return [] + rows = (Communication.query + .join(Asset, Communication.assetid == Asset.assetid) + .join(AssetType, Asset.assettypeid == AssetType.assettypeid) + .filter(Communication.isprimary == True, + Communication.ipaddress.isnot(None)) + .with_entities(Asset.assetid, Asset.assetnumber, Asset.name, + AssetType.assettype, Communication.ipaddress).all()) + result = [] + for assetid, assetnumber, name, assettype, ip in rows: + ip = (ip or '').strip() + if not ip: + continue + try: + if ipaddress.ip_address(ip) in net: + result.append({ + 'assetid': assetid, 'assetnumber': assetnumber, 'name': name, + 'assettype': assettype, 'ipaddress': ip, + 'url': _asset_detail_url(assettype, assetid), + }) + except (ValueError, TypeError): + continue + result.sort(key=lambda d: d['ipaddress']) + return result + + +def _asset_detail_url(assettype, assetid): + """Best-effort front-end detail path for an asset by type. Resolves the + per-type extension id lazily/guarded (subnet listings span plugins); returns + None when the plugin is absent so the frontend just shows the row.""" + try: + if assettype == 'network_device': + row = NetworkDevice.query.filter_by(assetid=assetid).first() + return f'/network/{row.networkdeviceid}' if row else None + if assettype == 'printer': + from plugins.printers.models import Printer + row = Printer.query.filter_by(assetid=assetid).first() + return f'/printers/{row.printerid}' if row else None + if assettype == 'computer': + from plugins.computers.models import Computer + row = Computer.query.filter_by(assetid=assetid).first() + return f'/pcs/{row.computerid}' if row else None + except ImportError: + return None + return None + + +@network_bp.route('/subnets', methods=['POST']) +@jwt_required() +@require_permission('network.create') +def create_subnet(): + """Create a new subnet.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('cidr'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr is required') + if not data.get('name'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + # Validate CIDR format (basic check) + cidr = data['cidr'] + if '/' not in cidr: + return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)') + + # Check for duplicate CIDR + if Subnet.query.filter_by(cidr=cidr).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Subnet {cidr} already exists", + http_code=409 + ) + + # Validate VLAN if provided + if data.get('vlanid'): + if not db.session.get(VLAN, data['vlanid']): + return error_response( + ErrorCodes.VALIDATION_ERROR, + f"VLAN with ID {data['vlanid']} not found" + ) + + subnet = Subnet( + cidr=cidr, + name=data['name'], + description=data.get('description'), + gatewayip=data.get('gatewayip'), + subnetmask=data.get('subnetmask'), + networkaddress=data.get('networkaddress'), + broadcastaddress=data.get('broadcastaddress'), + vlanid=data.get('vlanid'), + subnettype=data.get('subnettype'), + locationid=data.get('locationid'), + dhcpenabled=data.get('dhcpenabled', True), + dhcprangestart=data.get('dhcprangestart'), + dhcprangeend=data.get('dhcprangeend'), + dns1=data.get('dns1'), + dns2=data.get('dns2') + ) + + db.session.add(subnet) + db.session.flush() + + # Audit log + AuditLog.log('created', 'Subnet', entityid=subnet.subnetid, + entityname=f"{subnet.cidr} - {subnet.name}") + + db.session.commit() + + return success_response(subnet.to_dict(), message='Subnet created', http_code=201) + + +@network_bp.route('/subnets/', methods=['PUT']) +@jwt_required() +@require_permission('network.edit') +def update_subnet(subnet_id: int): + """Update a subnet.""" + subnet = db.session.get(Subnet, subnet_id) + + if not subnet: + return error_response( + ErrorCodes.NOT_FOUND, + f'Subnet with ID {subnet_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting CIDR + if 'cidr' in data and data['cidr'] != subnet.cidr: + if Subnet.query.filter_by(cidr=data['cidr']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Subnet {data['cidr']} already exists", + http_code=409 + ) + + allowed_fields = ['cidr', 'name', 'description', 'gatewayip', 'subnetmask', + 'networkaddress', 'broadcastaddress', 'vlanid', 'subnettype', + 'locationid', 'dhcpenabled', 'dhcprangestart', 'dhcprangeend', + 'dns1', 'dns2', 'isactive'] + + # Track changes for audit log + changes = {} + for key in allowed_fields: + if key in data: + old_val = getattr(subnet, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(subnet, key, data[key]) + + # Audit log if there were changes + if changes: + AuditLog.log('updated', 'Subnet', entityid=subnet.subnetid, + entityname=f"{subnet.cidr} - {subnet.name}", changes=changes) + + db.session.commit() + return success_response(subnet.to_dict(), message='Subnet updated') + + +@network_bp.route('/subnets/', methods=['DELETE']) +@jwt_required() +@require_permission('network.delete') +def delete_subnet(subnet_id: int): + """Delete (soft delete) a subnet.""" + subnet = db.session.get(Subnet, subnet_id) + + if not subnet: + return error_response( + ErrorCodes.NOT_FOUND, + f'Subnet with ID {subnet_id} not found', + http_code=404 + ) + + subnet.isactive = False + + # Audit log + AuditLog.log('deleted', 'Subnet', entityid=subnet.subnetid, + entityname=f"{subnet.cidr} - {subnet.name}") + + db.session.commit() + + return success_response(message='Subnet deleted') diff --git a/plugins/network/manifest.json b/plugins/network/manifest.json new file mode 100644 index 00000000..fa56c2b9 --- /dev/null +++ b/plugins/network/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "network", + "version": "1.0.0", + "description": "Network device management plugin for switches, APs, cameras, and IDFs", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/network", + "provides": { + "asset_type": "network_device", + "features": [ + "network_device_tracking", + "port_management", + "firmware_tracking", + "poe_monitoring" + ] + }, + "settings": { + "enable_snmp_polling": false, + "snmp_community": "public" + } +} diff --git a/plugins/network/migrations/env.py b/plugins/network/migrations/env.py new file mode 100644 index 00000000..03bc8d76 --- /dev/null +++ b/plugins/network/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the network plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_network. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'network' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/network/migrations/script.py.mako b/plugins/network/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/network/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/network/migrations/versions/0001_network_anchor.py b/plugins/network/migrations/versions/0001_network_anchor.py new file mode 100644 index 00000000..9ded03c1 --- /dev/null +++ b/plugins/network/migrations/versions/0001_network_anchor.py @@ -0,0 +1,28 @@ +"""network plugin anchor (ownership cutover). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_network. From this +anchor forward, new network schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'network0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/network/models/__init__.py b/plugins/network/models/__init__.py new file mode 100644 index 00000000..131748d9 --- /dev/null +++ b/plugins/network/models/__init__.py @@ -0,0 +1,11 @@ +"""Network plugin models.""" + +from .network_device import NetworkDevice, NetworkDeviceType +from .subnet import Subnet, VLAN + +__all__ = [ + 'NetworkDevice', + 'NetworkDeviceType', + 'Subnet', + 'VLAN', +] diff --git a/plugins/network/models/network_device.py b/plugins/network/models/network_device.py new file mode 100644 index 00000000..1ab6f73b --- /dev/null +++ b/plugins/network/models/network_device.py @@ -0,0 +1,120 @@ +"""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') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class NetworkDevice(BaseModel): + """ + Network device-specific extension data. + + Links to core Asset table via assetid. + Stores network device-specific fields like hostname, firmware, ports, etc. + """ + __tablename__ = 'networkdevices' + + networkdeviceid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Network device classification + networkdevicetypeid = db.Column( + db.Integer, + db.ForeignKey('networkdevicetypes.networkdevicetypeid'), + nullable=True + ) + + # Vendor + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + comment='Network hostname' + ) + + # Firmware/software version + firmwareversion = db.Column(db.String(100), nullable=True) + + # Physical characteristics + portcount = db.Column( + db.Integer, + nullable=True, + comment='Number of ports (for switches)' + ) + + # Features + ispoe = db.Column( + db.Boolean, + default=False, + comment='Power over Ethernet capable' + ) + ismanaged = db.Column( + db.Boolean, + default=False, + comment='Managed device (SNMP, web interface, etc.)' + ) + + # For IDF/closet locations + rackunit = db.Column( + db.String(20), + nullable=True, + comment='Rack unit position (e.g., U1, U5)' + ) + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('network_device', uselist=False, lazy='joined') + ) + networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices') + vendor = db.relationship('Vendor', backref='network_devices') + + __table_args__ = ( + db.Index('idx_netdev_type', 'networkdevicetypeid'), + db.Index('idx_netdev_hostname', 'hostname'), + db.Index('idx_netdev_vendor', 'vendorid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.networkdevicetype: + result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype + if self.vendor: + result['vendorname'] = self.vendor.vendor + + return result diff --git a/plugins/network/models/subnet.py b/plugins/network/models/subnet.py new file mode 100644 index 00000000..6a52b9f7 --- /dev/null +++ b/plugins/network/models/subnet.py @@ -0,0 +1,145 @@ +"""Subnet and VLAN models for network plugin.""" + +from shopdb.api import db, BaseModel + + +class VLAN(BaseModel): + """ + VLAN definition. + + Represents a virtual LAN for network segmentation. + """ + __tablename__ = 'vlans' + + vlanid = db.Column(db.Integer, primary_key=True) + vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number') + name = db.Column(db.String(100), nullable=False, comment='VLAN name') + description = db.Column(db.Text, nullable=True) + + # Optional classification + vlantype = db.Column( + db.String(50), + nullable=True, + comment='Type: data, voice, management, guest, etc.' + ) + + # Relationships + subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic') + + __table_args__ = ( + db.Index('idx_vlan_number', 'vlannumber'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary.""" + result = super().to_dict() + result['subnetcount'] = self.subnets.count() if self.subnets else 0 + return result + + +class Subnet(BaseModel): + """ + Subnet/IP network definition. + + Represents an IP subnet with optional VLAN association. + """ + __tablename__ = 'subnets' + + subnetid = db.Column(db.Integer, primary_key=True) + + # Network definition + cidr = db.Column( + db.String(18), + unique=True, + nullable=False, + comment='CIDR notation (e.g., 10.1.1.0/24)' + ) + name = db.Column(db.String(100), nullable=False, comment='Subnet name') + description = db.Column(db.Text, nullable=True) + + # Network details + gatewayip = db.Column( + db.String(15), + nullable=True, + comment='Default gateway IP address' + ) + subnetmask = db.Column( + db.String(15), + nullable=True, + comment='Subnet mask (e.g., 255.255.255.0)' + ) + networkaddress = db.Column( + db.String(15), + nullable=True, + comment='Network address (e.g., 10.1.1.0)' + ) + broadcastaddress = db.Column( + db.String(15), + nullable=True, + comment='Broadcast address (e.g., 10.1.1.255)' + ) + + # VLAN association + vlanid = db.Column( + db.Integer, + db.ForeignKey('vlans.vlanid'), + nullable=True + ) + + # Classification + subnettype = db.Column( + db.String(50), + nullable=True, + comment='Type: production, development, management, dmz, etc.' + ) + + # Location association + locationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True + ) + + # DHCP settings + dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet') + dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP') + dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP') + + # DNS settings + dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server') + dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server') + + # Relationships + location = db.relationship('Location', backref='subnets') + + __table_args__ = ( + db.Index('idx_subnet_cidr', 'cidr'), + db.Index('idx_subnet_vlan', 'vlanid'), + db.Index('idx_subnet_location', 'locationid'), + ) + + def __repr__(self): + return f"" + + @property + def vlan_number(self): + """Get the VLAN number.""" + return self.vlan.vlannumber if self.vlan else None + + def to_dict(self): + """Convert to dictionary with related data.""" + result = super().to_dict() + + # Add VLAN info + if self.vlan: + result['vlannumber'] = self.vlan.vlannumber + result['vlanname'] = self.vlan.name + + # Add location info + if self.location: + result['locationname'] = self.location.locationname + + return result diff --git a/plugins/network/plugin.py b/plugins/network/plugin.py new file mode 100644 index 00000000..903129c0 --- /dev/null +++ b/plugins/network/plugin.py @@ -0,0 +1,225 @@ +"""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, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('network.view', 'View network devices', 'network'), + ('network.create', 'Create network devices', 'network'), + ('network.edit', 'Edit network devices', 'network'), + ('network.delete', 'Delete network devices', 'network'), + ] diff --git a/plugins/notifications/__init__.py b/plugins/notifications/__init__.py new file mode 100644 index 00000000..edd7b58d --- /dev/null +++ b/plugins/notifications/__init__.py @@ -0,0 +1,5 @@ +"""Notifications plugin package.""" + +from .plugin import NotificationsPlugin + +__all__ = ['NotificationsPlugin'] diff --git a/plugins/notifications/api/__init__.py b/plugins/notifications/api/__init__.py new file mode 100644 index 00000000..d0385c2b --- /dev/null +++ b/plugins/notifications/api/__init__.py @@ -0,0 +1,5 @@ +"""Notifications plugin API.""" + +from .routes import notifications_bp + +__all__ = ['notifications_bp'] diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py new file mode 100644 index 00000000..e0006d7b --- /dev/null +++ b/plugins/notifications/api/routes.py @@ -0,0 +1,759 @@ +"""Notifications plugin API endpoints - adapted to existing schema.""" + +import hashlib +import os +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query + +from ..models import Notification, NotificationType + +from shopdb.api import require_permission + +notifications_bp = Blueprint('notifications', __name__) + +# Notification types whose multi-employee cards get split into one card per +# employee on the shopfloor dashboard. typecolor drives this (not typename), so +# recognition, training and recertification all fan out; every other type +# stays one card. +SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'}) + +# How long a card stays up on the shopfloor board when the creator does not set +# an explicit end time, by notification typecolor. +# recognition - clears at the next 8:00 AM Eastern (daily reset) +# recertification - stays up two weeks (employees have time to book the course) +EASTERN = ZoneInfo('America/New_York') +RECERTIFICATION_DAYS = 14 + + +def _next_eastern_time(after, hour, minute=0): + """Next hour:minute America/New_York strictly after `after` (naive UTC), + returned as naive UTC. Uses the tz database so it is correct across EST/EDT.""" + after_east = after.replace(tzinfo=timezone.utc).astimezone(EASTERN) + target = after_east.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0) + if target <= after_east: + target += timedelta(days=1) + return target.astimezone(timezone.utc).replace(tzinfo=None) + + +def _auto_endtime(ntype, starttime): + """Default display window for a notification with no explicit end time, from + the notification type's configured expiry rule: + 'dailytime' -> next expiryhour:expiryminute Eastern (daily reset) + 'duration' -> starttime + expirydays days + 'none' -> None (show indefinitely) + Falls back to the legacy typecolor rules when the expiry columns are unset, + so it is safe before/after the 7d03 migration.""" + mode = getattr(ntype, 'expirymode', None) or 'none' + if mode == 'dailytime': + hour = ntype.expiryhour if ntype.expiryhour is not None else 8 + return _next_eastern_time(starttime, hour, ntype.expiryminute or 0) + if mode == 'duration' and ntype.expirydays: + return starttime + timedelta(days=int(ntype.expirydays)) + if mode == 'none': + # legacy fallback for rule-bearing types created before the expiry columns + if getattr(ntype, 'typecolor', None) == 'recognition': + return _next_eastern_time(starttime, 8, 0) + if getattr(ntype, 'typecolor', None) == 'recertification': + return starttime + timedelta(days=RECERTIFICATION_DAYS) + return None + + +_EXPIRY_MODES = ('none', 'duration', 'dailytime') + + +def _apply_expiry_fields(t, data): + """Set expiry-rule columns on a NotificationType from request data. Only + touches fields that are present. Returns an error string, or None on ok.""" + if 'expirymode' in data: + mode = data.get('expirymode') or 'none' + if mode not in _EXPIRY_MODES: + return "expirymode must be one of: %s" % ", ".join(_EXPIRY_MODES) + t.expirymode = mode + if 'expirydays' in data: + v = data.get('expirydays') + if v in (None, ''): + t.expirydays = None + else: + try: + t.expirydays = int(v) + except (TypeError, ValueError): + return "expirydays must be an integer" + if t.expirydays < 1: + return "expirydays must be >= 1" + if 'expiryhour' in data: + v = data.get('expiryhour') + if v in (None, ''): + t.expiryhour = None + else: + try: + t.expiryhour = int(v) + except (TypeError, ValueError): + return "expiryhour must be an integer" + if not (0 <= t.expiryhour <= 23): + return "expiryhour must be 0-23" + if 'expiryminute' in data: + v = data.get('expiryminute') + try: + t.expiryminute = int(v) if v not in (None, '') else 0 + except (TypeError, ValueError): + return "expiryminute must be an integer" + if not (0 <= t.expiryminute <= 59): + return "expiryminute must be 0-59" + # cross-field consistency + mode = t.expirymode or 'none' + if mode == 'dailytime' and t.expiryhour is None: + t.expiryhour = 8 + if mode == 'duration' and not t.expirydays: + return "duration expiry requires expirydays >= 1" + return None + + +_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner') + + +def _apply_display_fields(t, data): + """Set shopfloor display-behavior columns on a NotificationType from request + data. Only touches fields that are present. Returns an error string, or None.""" + if 'splitperemployee' in data: + t.splitperemployee = bool(data.get('splitperemployee')) + if 'showemployeephoto' in data: + t.showemployeephoto = bool(data.get('showemployeephoto')) + if 'displaystyle' in data: + ds = data.get('displaystyle') or 'standard' + if ds not in _DISPLAY_STYLES: + return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES) + t.displaystyle = ds + return None + + +def _config_version(): + """Short hash of everything that changes the board's LAYOUT: per-type display + config plus an optional deploy stamp (SHOPFLOOR_BUILD env). The shopfloor + kiosks reload when this changes, so type/layout edits and frontend deploys + reach pages that are already open.""" + types = NotificationType.query.order_by(NotificationType.notificationtypeid).all() + parts = [ + "%s|%s|%s|%d|%d|%s|%s|%s|%d" % ( + t.notificationtypeid, t.typecolor, t.displaystyle, + int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)), + t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)), + ) + for t in types + ] + parts.append(os.environ.get('SHOPFLOOR_BUILD', '')) + return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12] + + +def _employee_picture(sso): + """Resolved display photo URL for an SSO, via the shared employees-plugin + resolver so kiosk cards match EmployeeDetail in both directory modes + (self-hosted upload URL or external HR URL). None on any miss.""" + try: + from plugins.employees.api.routes import resolve_employee_photo_url + return resolve_employee_photo_url(sso) + except Exception: + return None + + +# ============================================================================= +# Notification Types +# ============================================================================= + +@notifications_bp.route('/types', methods=['GET']) +def list_notification_types(): + """List all notification types.""" + page, per_page = get_pagination_params(request) + + query = NotificationType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(NotificationType.isactive == True) + + query = query.order_by(NotificationType.typename) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@notifications_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('notifications.create') +def create_notification_type(): + """Create a new notification type.""" + data = request.get_json() + + if not data or not data.get('typename'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required') + + if NotificationType.query.filter_by(typename=data['typename']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Notification type '{data['typename']}' already exists", + http_code=409 + ) + + t = NotificationType( + typename=data['typename'], + typedescription=data.get('typedescription') or data.get('description'), + typecolor=data.get('typecolor') or data.get('color', '#17a2b8') + ) + + err = _apply_expiry_fields(t, data) + if err: + return error_response(ErrorCodes.VALIDATION_ERROR, err) + err = _apply_display_fields(t, data) + if err: + return error_response(ErrorCodes.VALIDATION_ERROR, err) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Notification type created', http_code=201) + + +@notifications_bp.route('/types/', methods=['PUT', 'PATCH']) +@jwt_required() +@require_permission('notifications.create') +def update_notification_type(type_id: int): + """Update a notification type, including its auto-expiry rule.""" + t = db.session.get(NotificationType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, f'Notification type {type_id} not found', http_code=404) + + data = request.get_json() or {} + + if data.get('typename'): + dup = NotificationType.query.filter( + NotificationType.typename == data['typename'], + NotificationType.notificationtypeid != type_id + ).first() + if dup: + return error_response(ErrorCodes.CONFLICT, + f"Notification type '{data['typename']}' already exists", http_code=409) + t.typename = data['typename'] + if 'typedescription' in data or 'description' in data: + t.typedescription = data.get('typedescription') or data.get('description') + if 'typecolor' in data or 'color' in data: + t.typecolor = data.get('typecolor') or data.get('color') + if 'isactive' in data: + t.isactive = bool(data['isactive']) + + err = _apply_expiry_fields(t, data) + if err: + return error_response(ErrorCodes.VALIDATION_ERROR, err) + err = _apply_display_fields(t, data) + if err: + return error_response(ErrorCodes.VALIDATION_ERROR, err) + + db.session.commit() + return success_response(t.to_dict(), message='Notification type updated') + + +# ============================================================================= +# Notifications CRUD +# ============================================================================= + +@notifications_bp.route('', methods=['GET']) +def list_notifications(): + """ + List all notifications with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status (default: true) + - type_id: Filter by notification type ID + - current: Filter to currently active notifications only + - search: Search in notification text + """ + page, per_page = get_pagination_params(request) + + query = Notification.query + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Notification.isactive == True) + + # Type filter + if type_id := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Notification.notificationtypeid == int(type_id)) + + # Exact-match lookup for idempotent import. Notifications have no strong + # natural key; ticketnumber is the best available when a ticket is set. + if exactticket := request.args.get('ticketnumber'): + query = query.filter(Notification.ticketnumber == exactticket) + + # Current filter (active based on dates) + if request.args.get('current', 'false').lower() == 'true': + now = datetime.now(timezone.utc).replace(tzinfo=None) + query = query.filter( + Notification.starttime <= now, + db.or_( + Notification.endtime.is_(None), + Notification.endtime >= now + ) + ) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + Notification.notification.ilike(f'%{search}%') + ) + + # Sorting by start time (newest first) + query = query.order_by(Notification.starttime.desc()) + + items, total = paginate_query(query, page, per_page) + data = [n.to_dict() for n in items] + + return paginated_response(data, page, per_page, total) + + +@notifications_bp.route('/', methods=['GET']) +def get_notification(notification_id: int): + """Get a single notification.""" + n = db.session.get(Notification, notification_id) + + if not n: + return error_response( + ErrorCodes.NOT_FOUND, + f'Notification with ID {notification_id} not found', + http_code=404 + ) + + return success_response(n.to_dict()) + + +@notifications_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('notifications.create') +def create_notification(): + """Create a new notification.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + notification_text = data.get('notification') or data.get('message') + if not notification_text: + return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required') + + # Parse dates + starttime = datetime.now(timezone.utc).replace(tzinfo=None) + if data.get('starttime') or data.get('startdate'): + try: + date_str = data.get('starttime') or data.get('startdate') + starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format') + + endtime = None + if data.get('endtime') or data.get('enddate'): + try: + date_str = data.get('endtime') or data.get('enddate') + endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format') + + # No explicit end time: apply the per-type display window (recognition + # clears at the next 8 AM Eastern, recertification runs two weeks). + if endtime is None and data.get('notificationtypeid'): + ntype = db.session.get(NotificationType, data['notificationtypeid']) + if ntype: + endtime = _auto_endtime(ntype, starttime) + + n = Notification( + notification=notification_text, + notificationtypeid=data.get('notificationtypeid'), + businessunitid=data.get('businessunitid'), + appid=data.get('appid'), + starttime=starttime, + endtime=endtime, + ticketnumber=data.get('ticketnumber'), + link=data.get('link') or data.get('linkurl'), + isactive=True, + isshopfloor=data.get('isshopfloor', False), + employeesso=data.get('employeesso'), + employeename=data.get('employeename') + ) + + db.session.add(n) + db.session.commit() + + return success_response(n.to_dict(), message='Notification created', http_code=201) + + +@notifications_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('notifications.edit') +def update_notification(notification_id: int): + """Update a notification.""" + n = db.session.get(Notification, notification_id) + + if not n: + return error_response( + ErrorCodes.NOT_FOUND, + f'Notification with ID {notification_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Update text content + if 'notification' in data or 'message' in data: + n.notification = data.get('notification') or data.get('message') + + # Update simple fields + if 'notificationtypeid' in data: + n.notificationtypeid = data['notificationtypeid'] + if 'businessunitid' in data: + n.businessunitid = data['businessunitid'] + if 'appid' in data: + n.appid = data['appid'] + if 'ticketnumber' in data: + n.ticketnumber = data['ticketnumber'] + if 'link' in data or 'linkurl' in data: + n.link = data.get('link') or data.get('linkurl') + if 'isactive' in data: + n.isactive = data['isactive'] + if 'isshopfloor' in data: + n.isshopfloor = data['isshopfloor'] + if 'employeesso' in data: + n.employeesso = data['employeesso'] + if 'employeename' in data: + n.employeename = data['employeename'] + + # Parse and update dates + if 'starttime' in data or 'startdate' in data: + date_str = data.get('starttime') or data.get('startdate') + if date_str: + try: + n.starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format') + else: + n.starttime = datetime.now(timezone.utc).replace(tzinfo=None) + + if 'endtime' in data or 'enddate' in data: + date_str = data.get('endtime') or data.get('enddate') + if date_str: + try: + n.endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except ValueError: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format') + else: + n.endtime = None + + db.session.commit() + return success_response(n.to_dict(), message='Notification updated') + + +@notifications_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('notifications.delete') +def delete_notification(notification_id: int): + """Delete (soft delete) a notification.""" + n = db.session.get(Notification, notification_id) + + if not n: + return error_response( + ErrorCodes.NOT_FOUND, + f'Notification with ID {notification_id} not found', + http_code=404 + ) + + n.isactive = False + db.session.commit() + + return success_response(message='Notification deleted') + + +# ============================================================================= +# Special Endpoints +# ============================================================================= + +@notifications_bp.route('/active', methods=['GET']) +def get_active_notifications(): + """ + Get currently active notifications for display. + """ + now = datetime.now(timezone.utc).replace(tzinfo=None) + + from datetime import timedelta + lookahead = now + timedelta(days=10) + + notifications = Notification.query.filter( + Notification.isactive == True, + db.or_( + Notification.starttime.is_(None), + Notification.starttime <= lookahead + ), + db.or_( + Notification.endtime.is_(None), + Notification.endtime >= now + ) + ).order_by(Notification.starttime.asc()).all() + + data = [n.to_dict() for n in notifications] + + return success_response({ + 'notifications': data, + 'total': len(data) + }) + + +@notifications_bp.route('/calendar', methods=['GET']) +def get_calendar_events(): + """ + Get notifications in FullCalendar event format. + + Query parameters: + - start: Start date (ISO format) + - end: End date (ISO format) + """ + query = Notification.query.filter(Notification.isactive == True) + + # Date range filter + if start := request.args.get('start'): + try: + start_date = datetime.fromisoformat(start.replace('Z', '+00:00')) + query = query.filter( + db.or_( + Notification.endtime >= start_date, + Notification.endtime.is_(None) + ) + ) + except ValueError: + pass + + if end := request.args.get('end'): + try: + end_date = datetime.fromisoformat(end.replace('Z', '+00:00')) + query = query.filter(Notification.starttime <= end_date) + except ValueError: + pass + + notifications = query.order_by(Notification.starttime).all() + + events = [n.to_calendar_event() for n in notifications] + + return success_response(events) + + +@notifications_bp.route('/dashboard/summary', methods=['GET']) +def dashboard_summary(): + """Get notifications dashboard summary.""" + now = datetime.now(timezone.utc).replace(tzinfo=None) + + # Total active notifications + total_active = Notification.query.filter( + Notification.isactive == True, + db.or_( + Notification.starttime.is_(None), + Notification.starttime <= now + ), + db.or_( + Notification.endtime.is_(None), + Notification.endtime >= now + ) + ).count() + + # By type + by_type = db.session.query( + NotificationType.typename, + NotificationType.typecolor, + db.func.count(Notification.notificationid) + ).join(Notification + ).filter( + Notification.isactive == True + ).group_by(NotificationType.typename, NotificationType.typecolor + ).all() + + return success_response({ + 'active': total_active, + 'bytype': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type] + }) + + +@notifications_bp.route('/employee/', methods=['GET']) +def get_employee_recognitions(sso): + """ + Get recognitions for a specific employee by SSO. + + Returns all recognition-type notifications where the employee is mentioned. + """ + if not sso or not sso.isdigit(): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Valid SSO required') + + # Find recognition type(s) + recognition_types = NotificationType.query.filter( + db.or_( + NotificationType.typecolor == 'recognition', + NotificationType.typename.ilike('%recognition%') + ) + ).all() + + recognition_type_ids = [rt.notificationtypeid for rt in recognition_types] + + # Find notifications where this employee is mentioned + # Check both exact match and comma-separated list + query = Notification.query.filter( + Notification.isactive == True, + db.or_( + Notification.employeesso == sso, + Notification.employeesso.like(f'{sso},%'), + Notification.employeesso.like(f'%,{sso}'), + Notification.employeesso.like(f'%,{sso},%') + ) + ) + + # Optionally filter to recognition types only + if recognition_type_ids: + query = query.filter(Notification.notificationtypeid.in_(recognition_type_ids)) + + query = query.order_by(Notification.starttime.desc()) + + notifications = query.all() + data = [n.to_dict() for n in notifications] + + return success_response({ + 'recognitions': data, + 'total': len(data) + }) + + +@notifications_bp.route('/shopfloor', methods=['GET']) +def get_shopfloor_notifications(): + """ + Get notifications for shopfloor TV dashboard. + + Returns current and upcoming notifications with isshopfloor=1. + Splits multi-employee recognition and training into separate entries. + + Query parameters: + - businessunit: Filter by business unit ID (null = all units) + """ + from datetime import timedelta + + now = datetime.now(timezone.utc).replace(tzinfo=None) + business_unit = request.args.get('businessunit') + + # Base query for shopfloor notifications + base_query = Notification.query.filter(Notification.isshopfloor == True) + + # Business unit filter + if business_unit and business_unit.isdigit(): + # Specific BU: show that BU's notifications AND null (all units) + base_query = base_query.filter( + db.or_( + Notification.businessunitid == int(business_unit), + Notification.businessunitid.is_(None) + ) + ) + else: + # All units: only show notifications with NULL businessunitid + base_query = base_query.filter(Notification.businessunitid.is_(None)) + + # Current notifications (active now or ended within 30 minutes) + thirty_min_ago = now - timedelta(minutes=30) + current_query = base_query.filter( + db.or_( + # Active and currently showing + db.and_( + Notification.isactive == True, + db.or_(Notification.starttime.is_(None), Notification.starttime <= now), + db.or_(Notification.endtime.is_(None), Notification.endtime >= now) + ), + # Recently ended (within 30 min) - show as resolved + db.and_( + Notification.endtime.isnot(None), + Notification.endtime >= thirty_min_ago, + Notification.endtime < now + ) + ) + ).order_by(Notification.notificationid.desc()) + + current_notifications = current_query.all() + + # Upcoming notifications (starts within next 5 days) + five_days = now + timedelta(days=5) + upcoming_query = base_query.filter( + Notification.isactive == True, + Notification.starttime > now, + Notification.starttime <= five_days + ).order_by(Notification.starttime) + + upcoming_notifications = upcoming_query.all() + + def notification_to_shopfloor(n, employee_override=None): + """Convert notification to shopfloor format.""" + is_resolved = n.endtime and n.endtime < now + ntype = n.notificationtype + show_photo = bool(ntype and ntype.showemployeephoto) + + result = { + 'notificationid': n.notificationid, + 'notification': n.notification, + 'starttime': n.starttime.isoformat() if n.starttime else None, + 'endtime': n.endtime.isoformat() if n.endtime else None, + 'ticketnumber': n.ticketnumber, + 'link': n.link, + 'isactive': n.isactive, + 'isshopfloor': True, + 'resolved': is_resolved, + 'typename': ntype.typename if ntype else None, + 'typecolor': ntype.typecolor if ntype else None, + # Per-type display behavior the dashboard groups/renders by. + 'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard', + } + + # Employee info (photo only when the type wants it) + if employee_override: + result['employeesso'] = employee_override.get('sso') + result['employeename'] = employee_override.get('name') + result['employeepicture'] = employee_override.get('picture') + else: + result['employeesso'] = n.employeesso + result['employeename'] = n.employeename + result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None + + return result + + def expand(n): + """One shopfloor card per notification, or one per employee when the + type is configured to split multi-employee lists.""" + ntype = n.notificationtype + is_split = bool(ntype and ntype.splitperemployee) + if not (is_split and n.employeesso and ',' in n.employeesso): + return [notification_to_shopfloor(n)] + + show_photo = bool(ntype and ntype.showemployeephoto) + ssos = [s.strip() for s in n.employeesso.split(',')] + names = n.employeename.split(', ') if n.employeename else [] + return [ + notification_to_shopfloor(n, { + 'sso': sso, + 'name': names[i] if i < len(names) else sso, + 'picture': _employee_picture(sso) if show_photo else None, + }) + for i, sso in enumerate(ssos) + ] + + current_data = [card for n in current_notifications for card in expand(n)] + upcoming_data = [card for n in upcoming_notifications for card in expand(n)] + + return success_response({ + 'timestamp': now.isoformat(), + 'current': current_data, + 'upcoming': upcoming_data, + 'configversion': _config_version(), + }) diff --git a/plugins/notifications/manifest.json b/plugins/notifications/manifest.json new file mode 100644 index 00000000..889fcd78 --- /dev/null +++ b/plugins/notifications/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "notifications", + "version": "1.0.0", + "description": "Notifications and announcements management plugin", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/notifications", + "provides": { + "features": ["notifications", "announcements", "calendar_events"] + } +} diff --git a/plugins/notifications/migrations/env.py b/plugins/notifications/migrations/env.py new file mode 100644 index 00000000..71e63a68 --- /dev/null +++ b/plugins/notifications/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the notifications plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_notifications. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'notifications' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/notifications/migrations/script.py.mako b/plugins/notifications/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/notifications/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/notifications/migrations/versions/0001_notifications_anchor.py b/plugins/notifications/migrations/versions/0001_notifications_anchor.py new file mode 100644 index 00000000..a0d85ec5 --- /dev/null +++ b/plugins/notifications/migrations/versions/0001_notifications_anchor.py @@ -0,0 +1,28 @@ +"""notifications plugin anchor (ownership cutover). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_notifications. From this +anchor forward, new notifications schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'notifications0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/notifications/migrations/versions/0002_index_businessunitid.py b/plugins/notifications/migrations/versions/0002_index_businessunitid.py new file mode 100644 index 00000000..b14d4268 --- /dev/null +++ b/plugins/notifications/migrations/versions/0002_index_businessunitid.py @@ -0,0 +1,42 @@ +"""Index notifications.businessunitid (shopfloor list route filters on it). + +businessunitid is a soft-ref column the shopfloor feed filters by; it had no +index. Add ix_notifications_businessunitid (matches the model's index=True). + +Idempotent; downgrade drops it. + +Revision ID: notifications0002buidx +Revises: notifications0001anchor +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'notifications0002buidx' +down_revision = 'notifications0001anchor' +branch_labels = None +depends_on = None + +_INDEX = 'ix_notifications_businessunitid' + + +def _index_names(insp, table): + return {i['name'] for i in insp.get_indexes(table)} + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'notifications' not in insp.get_table_names(): + return + if _INDEX not in _index_names(insp, 'notifications'): + op.create_index(_INDEX, 'notifications', ['businessunitid']) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'notifications' not in insp.get_table_names(): + return + if _INDEX in _index_names(insp, 'notifications'): + op.drop_index(_INDEX, table_name='notifications') diff --git a/plugins/notifications/models/__init__.py b/plugins/notifications/models/__init__.py new file mode 100644 index 00000000..f6514a97 --- /dev/null +++ b/plugins/notifications/models/__init__.py @@ -0,0 +1,5 @@ +"""Notifications plugin models.""" + +from .notification import Notification, NotificationType + +__all__ = ['Notification', 'NotificationType'] diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py new file mode 100644 index 00000000..42616b7e --- /dev/null +++ b/plugins/notifications/models/notification.py @@ -0,0 +1,195 @@ +"""Notifications plugin models - adapted to existing database schema.""" + +from datetime import datetime, timezone +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) + + # Auto-expiry rule: when a notification of this type has no explicit end time, + # how long it stays up on the shopfloor board. + # 'none' -> indefinite (never auto-expires) + # 'duration' -> starttime + expirydays days + # 'dailytime' -> next expiryhour:expiryminute Eastern (daily reset) + expirymode = db.Column(db.String(20), default='none') + expirydays = db.Column(db.Integer, nullable=True) + expiryhour = db.Column(db.SmallInteger, nullable=True) + expiryminute = db.Column(db.SmallInteger, nullable=True, default=0) + + # Shopfloor display behavior (data-driven; replaces hardcoded per-type logic). + # splitperemployee -> one card per listed employee SSO + # showemployeephoto -> resolve + show each employee's photo + name + # displaystyle -> 'standard' (rows) | 'carousel' (rotating photo card) + # | 'grid' (cycling row of tiles) | 'banner' + splitperemployee = db.Column(db.Boolean, default=False) + showemployeephoto = db.Column(db.Boolean, default=False) + displaystyle = db.Column(db.String(20), default='standard') + + def __repr__(self): + return f"" + + def to_dict(self): + return { + 'notificationtypeid': self.notificationtypeid, + 'typename': self.typename, + 'typedescription': self.typedescription, + 'typecolor': self.typecolor, + 'isactive': self.isactive, + 'expirymode': self.expirymode or 'none', + 'expirydays': self.expirydays, + 'expiryhour': self.expiryhour, + 'expiryminute': self.expiryminute if self.expiryminute is not None else 0, + 'splitperemployee': bool(self.splitperemployee), + 'showemployeephoto': bool(self.showemployeephoto), + 'displaystyle': self.displaystyle or 'standard' + } + + +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, index=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) + # TEXT (not VARCHAR): recognition/recertification notifications comma-join + # every employee's SSO/name into one field, which overflows 100 chars once + # ~11 people are listed. + employeesso = db.Column(db.Text, nullable=True) + employeename = db.Column(db.Text, nullable=True) + + # Relationships + notificationtype = db.relationship('NotificationType', backref='notifications') + + def __repr__(self): + return f"" + + @property + def is_current(self): + """Check if notification is currently active based on dates.""" + now = datetime.now(timezone.utc).replace(tzinfo=None) + 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.""" + # Color is data-driven: types store a hex typecolor. Only the legacy + # Bootstrap color-name aliases still need translating; hex passes through. + color_aliases = { + 'success': '#04b962', + 'warning': '#ff8800', + 'danger': '#f5365c', + 'info': '#14abef', + 'primary': '#7934f3', + 'secondary': '#94614f', + } + + ntype = self.notificationtype + raw_color = ntype.typecolor if ntype else '#14abef' + color = color_aliases.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef') + show_photo = bool(ntype and getattr(ntype, 'showemployeephoto', False)) + + # Employee-photo types prefix the card with the person's name/SSO. A + # multi-person recognition/training lists many people, so summarize as + # "First Person +N" to keep the calendar title short (the detail popup + # still shows the full employeename). + title = self.title + if show_photo: + employee_display = self.employeename or self.employeesso + if employee_display: + people = [p.strip() for p in employee_display.split(',') if p.strip()] + if len(people) > 1: + employee_display = f"{people[0]} +{len(people) - 1}" + 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': ntype.typename if ntype else None, + 'typecolor': raw_color, + 'showemployeephoto': show_photo, + 'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard', + 'linkurl': self.link, + 'ticketnumber': self.ticketnumber, + 'employeename': self.employeename, + 'employeesso': self.employeesso, + } + } diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py new file mode 100644 index 00000000..44dad50d --- /dev/null +++ b/plugins/notifications/plugin.py @@ -0,0 +1,231 @@ +"""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. + + For the special shopfloor types (recognition, training, + recertification) the typecolor is a keyword the dashboard maps to a + style and the feed uses to split multi-employee cards per employee; + generic types use a hex color. + """ + # (typename, typedescription, typecolor, expirymode, expirydays, + # expiryhour, splitperemployee, showemployeephoto, displaystyle) + default_types = [ + ('Awareness', 'General awareness notification', '#17a2b8', 'none', None, None, False, False, 'standard'), + ('Change', 'Planned change notification', '#ffc107', 'none', None, None, False, False, 'standard'), + ('Incident', 'Incident or outage notification', '#dc3545', 'none', None, None, False, False, 'standard'), + ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'none', None, None, False, False, 'standard'), + ('General', 'General announcement', '#28a745', 'none', None, None, False, False, 'standard'), + ('Recognition', 'Employee recognition (clears at 8 AM Eastern)', '#ffc107', 'dailytime', None, 8, True, True, 'carousel'), + ('Training', 'Training notice (one card per employee)', '#17a2b8', 'none', None, None, True, True, 'carousel'), + ('Recertification', 'Employees due to retake a training course (shows two weeks)', '#0d6efd', 'duration', 14, None, True, True, 'grid'), + ] + + for (typename, typedescription, typecolor, expirymode, expirydays, + expiryhour, splitperemployee, showemployeephoto, displaystyle) in default_types: + existing = NotificationType.query.filter_by(typename=typename).first() + if not existing: + t = NotificationType( + typename=typename, + typedescription=typedescription, + typecolor=typecolor, + expirymode=expirymode, + expirydays=expirydays, + expiryhour=expiryhour, + expiryminute=0, + splitperemployee=splitperemployee, + showemployeephoto=showemployeephoto, + displaystyle=displaystyle + ) + 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, timezone + + with current_app.app_context(): + now = datetime.now(timezone.utc).replace(tzinfo=None) + + 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, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('notifications.view', 'View notifications', 'notifications'), + ('notifications.create', 'Create notifications', 'notifications'), + ('notifications.edit', 'Edit notifications', 'notifications'), + ('notifications.delete', 'Delete notifications', 'notifications'), + ] diff --git a/plugins/printers/__init__.py b/plugins/printers/__init__.py new file mode 100644 index 00000000..bc7220df --- /dev/null +++ b/plugins/printers/__init__.py @@ -0,0 +1,5 @@ +"""Printers plugin - extends machines with printer-specific functionality.""" + +from .plugin import PrintersPlugin + +__all__ = ['PrintersPlugin'] diff --git a/plugins/printers/api/__init__.py b/plugins/printers/api/__init__.py new file mode 100644 index 00000000..5226ee0e --- /dev/null +++ b/plugins/printers/api/__init__.py @@ -0,0 +1,7 @@ +"""Printers plugin API.""" + +from .asset_routes import printers_asset_bp # Asset-based API + +__all__ = [ + 'printers_asset_bp', +] diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py new file mode 100644 index 00000000..8a95e985 --- /dev/null +++ b/plugins/printers/api/asset_routes.py @@ -0,0 +1,1156 @@ +"""Printers API routes - new Asset-based architecture.""" + +import logging + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query + +from ..models import Printer, PrinterType, ModelSupply, PrinterDriver +from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS +from ..services import ( + ZabbixService, + classifysupply, + derivesupplytype, + derivecolor, + lookupsupplies, +) + +logger = logging.getLogger(__name__) + +from shopdb.api import require_permission, apply_import_timestamps + +printers_asset_bp = Blueprint('printers_asset', __name__) + + +# ============================================================================= +# Printer Types +# ============================================================================= + +@printers_asset_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_printer_types(): + """List all printer types.""" + page, per_page = get_pagination_params(request) + + query = PrinterType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(PrinterType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(PrinterType.printertype.ilike(f'%{search}%')) + + query = query.order_by(PrinterType.printertype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_printer_type(type_id: int): + """Get a single printer type.""" + t = db.session.get(PrinterType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@printers_asset_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def create_printer_type(): + """Create a new printer type.""" + data = request.get_json() + + if not data or not data.get('printertype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'printertype is required') + + existing = PrinterType.query.filter_by(printertype=data['printertype']).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'icon', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing type') + return error_response( + ErrorCodes.CONFLICT, + f"Printer type '{data['printertype']}' already exists", + http_code=409 + ) + + t = PrinterType( + printertype=data['printertype'], + description=data.get('description'), + icon=data.get('icon'), color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Printer type created', http_code=201) + + +@printers_asset_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('printers.edit') +def update_printer_type(type_id: int): + """Update a printer type.""" + t = db.session.get(PrinterType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, + f'Printer type with ID {type_id} not found', http_code=404) + + data = request.get_json() or {} + if 'printertype' in data and data['printertype'] != t.printertype: + if PrinterType.query.filter_by(printertype=data['printertype']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Printer type '{data['printertype']}' already exists", http_code=409) + + for key in ['printertype', 'description', 'icon', 'color', 'isactive']: + if key in data: + setattr(t, key, data[key]) + + db.session.commit() + return success_response(t.to_dict(), message='Printer type updated') + + +@printers_asset_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_permission('printers.delete') +def delete_printer_type(type_id: int): + """Delete a printer type. Refused if any printer still uses it.""" + t = db.session.get(PrinterType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Printer type not found', http_code=404) + inuse = Printer.query.filter_by(printertypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} printer(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Printer type deleted') + + +# ============================================================================= +# Printer Drivers (named SMB / HTTP links to driver packages) +# ============================================================================= + +@printers_asset_bp.route('/drivers', methods=['GET']) +@jwt_required(optional=True) +def list_drivers(): + """List printer drivers. ?active=false includes inactive ones.""" + query = PrinterDriver.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter_by(isactive=True) + drivers = query.order_by(PrinterDriver.name).all() + return success_response([d.to_dict() for d in drivers]) + + +@printers_asset_bp.route('/drivers', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def create_driver(): + data = request.get_json() or {} + if not (data.get('name') and data.get('location')): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name and location are required') + d = PrinterDriver( + name=data['name'], + location=data['location'], + description=data.get('description'), + modelnumberid=data.get('modelnumberid') or None, + isactive=data.get('isactive', True), + ) + db.session.add(d) + db.session.commit() + return success_response(d.to_dict(), message='Driver created', http_code=201) + + +@printers_asset_bp.route('/drivers/', methods=['PUT']) +@jwt_required() +@require_permission('printers.edit') +def update_driver(driver_id): + d = db.session.get(PrinterDriver, driver_id) + if not d: + return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404) + data = request.get_json() or {} + for key in ('name', 'location', 'description', 'isactive'): + if key in data: + setattr(d, key, data[key]) + if 'modelnumberid' in data: + d.modelnumberid = data['modelnumberid'] or None + db.session.commit() + return success_response(d.to_dict(), message='Driver updated') + + +@printers_asset_bp.route('/drivers/', methods=['DELETE']) +@jwt_required() +@require_permission('printers.delete') +def delete_driver(driver_id): + d = db.session.get(PrinterDriver, driver_id) + if not d: + return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404) + db.session.delete(d) + db.session.commit() + return success_response(message='Driver deleted') + + +# ============================================================================= +# Printers CRUD +# ============================================================================= + +@printers_asset_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_printers(): + """ + List all printers with filtering and pagination. + + Query parameters: + - page, per_page: Pagination + - active: Filter by active status + - search: Search by asset number, name, or hostname + - type_id: Filter by printer type ID + - vendor_id: Filter by vendor ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + """ + page, per_page = get_pagination_params(request) + + # Join Printer with Asset + query = db.session.query(Printer).join(Asset) + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Exact-match natural-key lookup for idempotent import (asset number). + if exactassetnumber := request.args.get('assetnumber'): + query = query.filter(Asset.assetnumber == exactassetnumber) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%'), + Printer.hostname.ilike(f'%{search}%'), + Printer.windowsname.ilike(f'%{search}%') + ) + ) + + # Type filter + if typeid := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Printer.printertypeid == int(typeid)) + + # Vendor filter + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): + query = query.filter(Printer.vendorid == int(vendor_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'hostname') + sort_dir = request.args.get('dir', 'asc') + + if sort_by == 'hostname': + col = Printer.hostname + elif sort_by == 'assetnumber': + col = Asset.assetnumber + elif sort_by == 'name': + col = Asset.name + else: + col = Printer.hostname + + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + + items, total = paginate_query(query, page, per_page) + + # Build response with both asset and printer data + data = [] + for printer in items: + item = printer.asset.to_dict() if printer.asset else {} + item['printer'] = printer.to_dict() + + # Add primary IP address + if printer.asset: + primary_comm = Communication.query.filter_by( + assetid=printer.asset.assetid, + isprimary=True + ).first() + if not primary_comm: + primary_comm = Communication.query.filter_by( + assetid=printer.asset.assetid + ).first() + item['ipaddress'] = primary_comm.ipaddress if primary_comm else None + + data.append(item) + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/install-list', methods=['GET']) +@jwt_required(optional=True) +def printer_install_list(): + """Flat, unpaginated list of network printers for the printer installer. + + Shopfloor 2.0 PCs cannot run unsigned .bat maps, so the signed installer EXE + pulls printer data + floor-map positions from here and renders the picker. + Replaces the classic apiprinters.asp contract, adding mapx/mapy. Network + printers only (USB-only printers are excluded). + """ + rows = [] + query = db.session.query(Printer).join(Asset).filter(Asset.isactive == True) + for printer in query.all(): + asset = printer.asset + if not asset: + continue + primary = Communication.query.filter_by( + assetid=asset.assetid, isprimary=True).first() \ + or Communication.query.filter_by(assetid=asset.assetid).first() + ipaddress = primary.ipaddress if primary else None + + # Network printers only: must have a hostname or a non-USB IP. + is_network = bool(printer.hostname) or (ipaddress and ipaddress != 'USB') + if not is_network: + continue + + data = printer.to_dict() + rows.append({ + 'printerid': printer.printerid, + 'name': asset.name or asset.assetnumber, + 'machinenumber': asset.assetnumber, + 'windowsname': printer.windowsname, + 'sharename': printer.sharename, + 'hostname': printer.hostname, + 'ipaddress': ipaddress, + 'vendorname': data.get('vendorname'), + 'modelnumber': data.get('modelname'), + 'installpath': printer.installpath, + 'iscsf': printer.iscsf, + 'locationname': asset.location.locationname if asset.location else None, + 'mapx': asset.mapx, + 'mapy': asset.mapy, + }) + + return success_response(rows) + + +@printers_asset_bp.route('/pc-default', methods=['GET']) +@jwt_required(optional=True) +def pc_default_printer(): + """Default printer for a PC, by machine (asset) number. + + Parity with classic apipcdefaultprinter.asp: the signed installer EXE + preselects a PC's default-printer hotspot on the site-map wizard using the + machine number persisted at PXE enrollment. The link is a `defaultprinter` + asset relationship (PC asset -> printer asset), so this stays inside the + contract surface (no cross-plugin model import). + + Returns {printerid, windowsname}, or {} when the machine is unknown or has + no active default printer set. + """ + machine = (request.args.get('machine') or '').strip() + if not machine: + return success_response({}) + + pc = Asset.query.filter_by(assetnumber=machine, isactive=True).first() + dp_type = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first() + if not pc or not dp_type: + return success_response({}) + + rel = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, + relationshiptypeid=dp_type.relationshiptypeid, + isactive=True, + ).first() + if not rel: + return success_response({}) + + printer = db.session.query(Printer).join(Asset).filter( + Printer.assetid == rel.targetassetid, + Asset.isactive == True, + ).first() + if not printer: + return success_response({}) + + return success_response({ + 'printerid': printer.printerid, + 'windowsname': printer.windowsname, + }) + + +@printers_asset_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_printer(printer_id: int): + """Get a single printer with full details.""" + printer = db.session.get(Printer, printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + result = printer.asset.to_dict() if printer.asset else {} + result['printer'] = printer.to_dict() + + # Add communications + if printer.asset: + comms = Communication.query.filter_by(assetid=printer.asset.assetid).all() + result['communications'] = [c.to_dict() for c in comms] + + # Attach active drivers that match this printer's model + if printer.modelnumberid: + drivers = PrinterDriver.query.filter_by( + modelnumberid=printer.modelnumberid, isactive=True + ).order_by(PrinterDriver.name).all() + result['drivers'] = [d.to_dict() for d in drivers] + else: + result['drivers'] = [] + + return success_response(result) + + +@printers_asset_bp.route('/by-asset/', methods=['GET']) +@jwt_required(optional=True) +def get_printer_by_asset(asset_id: int): + """Get printer data by asset ID.""" + printer = Printer.query.filter_by(assetid=asset_id).first() + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer for asset {asset_id} not found', + http_code=404 + ) + + result = printer.asset.to_dict() if printer.asset else {} + result['printer'] = printer.to_dict() + + return success_response(result) + + +@printers_asset_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def create_printer(): + """ + Create new printer (creates both Asset and Printer records). + + Required fields: + - assetnumber: Business identifier + + Optional fields: + - name, serialnumber, statusid, locationid, businessunitid + - printertypeid, vendorid, modelnumberid, hostname + - windowsname, sharename, iscsf, installpath, pin + - iscolor, isduplex, isnetwork + - mapx, mapy, notes + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Get printer asset type + printer_type = AssetType.query.filter_by(assettype='printer').first() + if not printer_type: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Printer asset type not found. Plugin may not be properly installed.', + http_code=500 + ) + + # Create the core asset + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), + assettypeid=printer_type.assettypeid, + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + db.session.flush() # Get the assetid + + # Create the printer extension + printer = Printer( + assetid=asset.assetid, + printertypeid=data.get('printertypeid'), + vendorid=data.get('vendorid'), + modelnumberid=data.get('modelnumberid'), + hostname=data.get('hostname'), + windowsname=data.get('windowsname'), + sharename=data.get('sharename'), + iscsf=data.get('iscsf', False), + installpath=data.get('installpath'), + pin=data.get('pin'), + iscolor=data.get('iscolor', False), + isduplex=data.get('isduplex', False), + isnetwork=data.get('isnetwork', True) + ) + + db.session.add(printer) + + # Create communication record if IP provided + if data.get('ipaddress'): + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + comm = Communication( + assetid=asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress=data['ipaddress'], + isprimary=True + ) + db.session.add(comm) + + # Preserve legacy timestamps in import mode (no-op otherwise) + apply_import_timestamps(asset, data) + + db.session.commit() + + result = asset.to_dict() + result['printer'] = printer.to_dict() + + return success_response(result, message='Printer created', http_code=201) + + +@printers_asset_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('printers.edit') +def update_printer(printer_id: int): + """Update printer (both Asset and Printer records).""" + printer = db.session.get(Printer, printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + asset = printer.asset + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Update asset fields (optional identifiers gated per-type in Settings) + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', + 'notes', 'isactive'] + for key in asset_fields: + if key in data: + setattr(asset, key, data[key]) + + # Update printer fields + printer_fields = ['printertypeid', 'vendorid', 'modelnumberid', 'hostname', + 'windowsname', 'sharename', 'iscsf', 'installpath', 'pin', + 'iscolor', 'isduplex', 'isnetwork'] + for key in printer_fields: + if key in data: + setattr(printer, key, data[key]) + + # Upsert the primary IP communication when an ipaddress is supplied, so a + # single PUT updates core, extension, and network in one call. + if 'ipaddress' in data: + ip = (data.get('ipaddress') or '').strip() + comm = Communication.query.filter_by( + assetid=asset.assetid, isprimary=True).first() + if ip: + if comm: + comm.ipaddress = ip + else: + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + db.session.add(Communication( + assetid=asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress=ip, + isprimary=True, + )) + elif comm: + comm.ipaddress = None + + apply_import_timestamps(asset, data) + db.session.commit() + + result = asset.to_dict() + result['printer'] = printer.to_dict() + + return success_response(result, message='Printer updated') + + +@printers_asset_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('printers.delete') +def delete_printer(printer_id: int): + """Delete (soft delete) printer.""" + printer = db.session.get(Printer, printer_id) + + if not printer: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer with ID {printer_id} not found', + http_code=404 + ) + + # Soft delete the asset + printer.asset.isactive = False + db.session.commit() + + return success_response(message='Printer deleted') + + +# ============================================================================= +# Supply Levels (Zabbix Integration) +# ============================================================================= + +@printers_asset_bp.route('//supplies', methods=['GET']) +@jwt_required(optional=True) +def get_printer_supplies(printer_id: int): + """Get supply levels from Zabbix (real-time lookup).""" + printer = db.session.get(Printer, printer_id) + + if not printer: + return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) + + # Get IP address from communications + comm = Communication.query.filter_by( + assetid=printer.assetid, + isprimary=True + ).first() + if not comm: + comm = Communication.query.filter_by(assetid=printer.assetid).first() + + if not comm or not comm.ipaddress: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') + + service = ZabbixService() + if not service.isconfigured or not service.isreachable: + # fail soft when zabbix off or down + return success_response({ + 'ipaddress': comm.ipaddress, + 'pingstatus': '-1', + 'supplies': [] + }) + + # vendor drives waste-cartridge rules; modelnumberid drives part lookup + vendor_name = printer.vendor.vendor if getattr(printer, 'vendor', None) else None + + raw_supplies = service.getsuppliesbyip(comm.ipaddress) or [] + supplies = [ + _annotate_supply(s, vendor_name, printer.modelnumberid) for s in raw_supplies + ] + + return success_response({ + 'ipaddress': comm.ipaddress, + 'pingstatus': service.getpingstatus(comm.ipaddress), + 'supplies': supplies + }) + + +# ============================================================================= +# Low Supplies +# ============================================================================= + +def _annotate_supply(supply, vendor_name, modelnumberid): + """Add status, remaining percent, and part numbers to a raw supply dict. + + Waste cartridge direction depends on vendor, so classification lives in + the supply_parts helper. Part numbers come from the modelsupplies table. + """ + level = supply.get('level', 0) + name = supply.get('name', 'Unknown') + supplytype = derivesupplytype(name) + color = derivecolor(name, supply.get('color')) + cls = classifysupply(level, name, vendor_name) + return { + 'name': name, + 'level': level, + 'color': color, + 'supplytype': supplytype, + 'status': cls['status'], + 'remaining': cls['remaining'], + 'iswaste': cls['iswaste'], + 'isdrum': cls['isdrum'], + 'partnumbers': lookupsupplies(modelnumberid, color, supplytype), + } + + +def _get_low_supplies_data(): + """Build low supplies data (cached for 5 minutes).""" + cached = cache.get('printers_low_supplies') + if cached is not None: + return cached + + service = ZabbixService() + if not service.isconfigured or not service.isreachable: + return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}} + + # active printers with an IP, with vendor and model for waste/part rules + rows = ( + db.session.query(Printer, Asset, Communication, Vendor, Model) + .join(Asset, Asset.assetid == Printer.assetid) + .join(Communication, Communication.assetid == Asset.assetid) + .outerjoin(Vendor, Vendor.vendorid == Printer.vendorid) + .outerjoin(Model, Model.modelnumberid == Printer.modelnumberid) + .filter(Asset.isactive == True) + .filter(Communication.ipaddress.isnot(None)) + .filter(Communication.ipaddress != '') + .all() + ) + + # dedupe by printer id (a printer may have several comms) + seen = set() + unique_printers = [] + for printer, asset, comm, vendor, model in rows: + if printer.printerid not in seen: + seen.add(printer.printerid) + unique_printers.append((printer, asset, comm, vendor, model)) + + results = [] + total_checked = 0 + + for printer, asset, comm, vendor, model in unique_printers: + supplies = service.getsuppliesbyip_cached(comm.ipaddress) + if supplies is None: + continue + + total_checked += 1 + + vendor_name = vendor.vendor if vendor else None + model_number = model.modelnumber if model else None + modelnumberid = model.modelnumberid if model else None + + annotated = [] + has_low = False + for s in supplies: + item = _annotate_supply(s, vendor_name, modelnumberid) + if item['status'] != 'ok': + has_low = True + annotated.append(item) + + if has_low: + # location name for the report row + location_name = None + if asset.locationid: + from shopdb.api import Location + loc = db.session.get(Location, asset.locationid) + if loc: + location_name = loc.locationname + + results.append({ + 'printerid': printer.printerid, + 'printername': asset.name or printer.hostname or '', + 'assetnumber': asset.assetnumber or '', + 'ipaddress': comm.ipaddress, + 'vendor': vendor_name, + 'model': model_number, + 'location': location_name, + 'supplies': annotated + }) + + low_count = 0 + critical_count = 0 + for p in results: + has_critical = any(s['status'] == 'critical' for s in p['supplies']) + has_low = any(s['status'] == 'low' for s in p['supplies']) + if has_critical: + critical_count += 1 + elif has_low: + low_count += 1 + + data = { + 'printers': results, + 'summary': { + 'total_checked': total_checked, + 'low': low_count, + 'critical': critical_count + } + } + + cache.set('printers_low_supplies', data, timeout=300) + return data + + +@printers_asset_bp.route('/lowsupplies', methods=['GET']) +@jwt_required(optional=True) +def low_supplies(): + """Get printers with low or critical supply levels.""" + data = _get_low_supplies_data() + return success_response(data) + + +@printers_asset_bp.route('/lookup', methods=['GET']) +@jwt_required(optional=True) +def printer_lookup(): + """Find a printer by IP or FQDN. Parity with the classic printerlookup.asp. + + Zabbix uses this to jump straight to a printer record. Query with + ?ip=x.x.x.x or ?fqdn=hostname; returns the matching printer id. + """ + ip = (request.args.get('ip') or '').strip() + fqdn = (request.args.get('fqdn') or '').strip() + lookup_value = ip or fqdn + + if not lookup_value: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Provide ip or fqdn' + ) + + # match the IP against any active printer communication + row = ( + db.session.query(Printer, Asset) + .join(Asset, Asset.assetid == Printer.assetid) + .join(Communication, Communication.assetid == Asset.assetid) + .filter(Asset.isactive == True) + .filter(Communication.ipaddress == lookup_value) + .first() + ) + + if not row: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer not found: {lookup_value}', + http_code=404 + ) + + printer, asset = row + return success_response({ + 'printerid': printer.printerid, + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'name': asset.name or printer.hostname, + }) + + +@printers_asset_bp.route('/supplies/refresh', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def refresh_supplies_cache(): + """Clear cached Zabbix supply data so the next read pulls fresh values. + + Backs the toner report Refresh button (parity with adminclearcache.asp + type=zabbix). + """ + ZabbixService().clearcache() + return success_response(message='Supply cache cleared') + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@printers_asset_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get printer dashboard summary data.""" + # Total active printers + total = db.session.query(Printer).join(Asset).filter( + Asset.isactive == True + ).count() + + # Count by printer type + by_type = db.session.query( + PrinterType.printertype, + db.func.count(Printer.printerid) + ).join(Printer, Printer.printertypeid == PrinterType.printertypeid + ).join(Asset, Asset.assetid == Printer.assetid + ).filter(Asset.isactive == True + ).group_by(PrinterType.printertype + ).all() + + # Count by vendor + by_vendor = db.session.query( + Vendor.vendor, + db.func.count(Printer.printerid) + ).join(Printer, Printer.vendorid == Vendor.vendorid + ).join(Asset, Asset.assetid == Printer.assetid + ).filter(Asset.isactive == True + ).group_by(Vendor.vendor + ).all() + + # Get real low/critical supply counts (skip if Zabbix not reachable) + low_count = 0 + critical_count = 0 + service = ZabbixService() + if service.isconfigured and service.isreachable: + try: + supply_data = _get_low_supplies_data() + low_count = supply_data['summary']['low'] + critical_count = supply_data['summary']['critical'] + except Exception as e: + logger.warning(f"Could not fetch supply data for dashboard: {e}") + + return success_response({ + 'total': total, + 'totalprinters': total, + 'online': total, + 'lowsupplies': low_count, + 'criticalsupplies': critical_count, + 'bytype': [{'type': t, 'count': c} for t, c in by_type], + 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], + }) + + +# ============================================================================= +# Model Supplies (data-driven toner/drum/waste part numbers) +# ============================================================================= + +def _validate_supply_payload(data): + """Return an error message if the supply payload is invalid, else None.""" + if not data: + return 'No data provided' + if not data.get('partnumber'): + return 'partnumber is required' + supplytype = data.get('supplytype', 'toner') + if supplytype not in SUPPLY_TYPES: + return f"supplytype must be one of {', '.join(SUPPLY_TYPES)}" + color = data.get('color', 'none') + if color not in SUPPLY_COLORS: + return f"color must be one of {', '.join(SUPPLY_COLORS)}" + capacitytier = data.get('capacitytier', 'standard') + if capacitytier not in CAPACITY_TIERS: + return f"capacitytier must be one of {', '.join(CAPACITY_TIERS)}" + return None + + +@printers_asset_bp.route('/supplies/meta', methods=['GET']) +@jwt_required(optional=True) +def supplies_meta(): + """Allowed values for supply type, color, and capacity tier (for the UI).""" + return success_response({ + 'supplytypes': list(SUPPLY_TYPES), + 'colors': list(SUPPLY_COLORS), + 'capacitytiers': list(CAPACITY_TIERS), + }) + + +@printers_asset_bp.route('/models', methods=['GET']) +@jwt_required(optional=True) +def list_supply_models(): + """List models with a supply count, for the supply-management picker. + + Query parameters: + - search: filter by model number + - vendor_id: filter by vendor + - withsupplies: 'true' to only return models that already have supplies + """ + page, per_page = get_pagination_params(request) + + supplycount = db.func.count(ModelSupply.modelsupplyid).label('supplycount') + query = ( + db.session.query(Model, Vendor.vendor, supplycount) + .outerjoin(Vendor, Vendor.vendorid == Model.vendorid) + .outerjoin(ModelSupply, db.and_( + ModelSupply.modelnumberid == Model.modelnumberid, + ModelSupply.isactive == True, + )) + .group_by(Model.modelnumberid, Vendor.vendor) + ) + + # Toner/drum/waste only apply to printers, so restrict the picker to + # printer models: those attached to a printer asset, or those that already + # carry supply mappings. Keeps machine/controller models out of the list. + printer_model_ids = ( + db.session.query(Printer.modelnumberid) + .filter(Printer.modelnumberid.isnot(None)) + ) + supply_model_ids = db.session.query(ModelSupply.modelnumberid) + query = query.filter(db.or_( + Model.modelnumberid.in_(printer_model_ids), + Model.modelnumberid.in_(supply_model_ids), + )) + + if search := request.args.get('search'): + query = query.filter(Model.modelnumber.ilike(f'%{search}%')) + if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')): + query = query.filter(Model.vendorid == int(vendor_id)) + if request.args.get('withsupplies', '').lower() == 'true': + query = query.having(supplycount > 0) + + query = query.order_by(Model.modelnumber) + + total = query.count() + rows = query.limit(per_page).offset((page - 1) * per_page).all() + + data = [{ + 'modelnumberid': model.modelnumberid, + 'modelnumber': model.modelnumber, + 'vendor': vendor, + 'vendorid': model.vendorid, + 'supplycount': count, + } for model, vendor, count in rows] + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/models//supplies', methods=['GET']) +@jwt_required(optional=True) +def list_model_supplies(modelnumberid: int): + """List all supplies mapped to a model.""" + model = db.session.get(Model, modelnumberid) + if not model: + return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) + + supplies = ( + ModelSupply.query + .filter_by(modelnumberid=modelnumberid, isactive=True) + .order_by(ModelSupply.supplytype, ModelSupply.color, ModelSupply.capacitytier) + .all() + ) + return success_response({ + 'modelnumberid': modelnumberid, + 'modelnumber': model.modelnumber, + 'supplies': [s.to_dict() for s in supplies], + }) + + +@printers_asset_bp.route('/models//supplies', methods=['POST']) +@jwt_required() +@require_permission('printers.create') +def create_model_supply(modelnumberid: int): + """Add a supply to a model.""" + model = db.session.get(Model, modelnumberid) + if not model: + return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) + + data = request.get_json() + message = _validate_supply_payload(data) + if message: + return error_response(ErrorCodes.VALIDATION_ERROR, message) + + existing = ModelSupply.query.filter_by( + modelnumberid=modelnumberid, + partnumber=data['partnumber'], + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Part number '{data['partnumber']}' already mapped to this model", + http_code=409, + ) + + supply = ModelSupply( + modelnumberid=modelnumberid, + supplytype=data.get('supplytype', 'toner'), + color=data.get('color', 'none'), + capacitytier=data.get('capacitytier', 'standard'), + partnumber=data['partnumber'], + marketingname=data.get('marketingname'), + pageyield=data.get('pageyield'), + notes=data.get('notes'), + ) + db.session.add(supply) + db.session.commit() + + return success_response(supply.to_dict(), message='Supply added', http_code=201) + + +@printers_asset_bp.route('/supplies/', methods=['PUT']) +@jwt_required() +@require_permission('printers.edit') +def update_model_supply(modelsupplyid: int): + """Update a model supply.""" + supply = db.session.get(ModelSupply, modelsupplyid) + if not supply: + return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # validate only the fields present + merged = { + 'partnumber': data.get('partnumber', supply.partnumber), + 'supplytype': data.get('supplytype', supply.supplytype), + 'color': data.get('color', supply.color), + 'capacitytier': data.get('capacitytier', supply.capacitytier), + } + message = _validate_supply_payload(merged) + if message: + return error_response(ErrorCodes.VALIDATION_ERROR, message) + + if 'partnumber' in data and data['partnumber'] != supply.partnumber: + clash = ModelSupply.query.filter_by( + modelnumberid=supply.modelnumberid, + partnumber=data['partnumber'], + ).first() + if clash: + return error_response( + ErrorCodes.CONFLICT, + f"Part number '{data['partnumber']}' already mapped to this model", + http_code=409, + ) + + for field in ('supplytype', 'color', 'capacitytier', 'partnumber', + 'marketingname', 'pageyield', 'notes'): + if field in data: + setattr(supply, field, data[field]) + + db.session.commit() + return success_response(supply.to_dict(), message='Supply updated') + + +@printers_asset_bp.route('/supplies/', methods=['DELETE']) +@jwt_required() +@require_permission('printers.delete') +def delete_model_supply(modelsupplyid: int): + """Delete a model supply.""" + supply = db.session.get(ModelSupply, modelsupplyid) + if not supply: + return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) + + db.session.delete(supply) + db.session.commit() + return success_response(message='Supply deleted') diff --git a/plugins/printers/manifest.json b/plugins/printers/manifest.json new file mode 100644 index 00000000..bb043cca --- /dev/null +++ b/plugins/printers/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "printers", + "version": "1.0.0", + "description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/printers", + "provides": { + "machine_category": "Printer", + "features": [ + "printer_extensions", + "driver_management", + "supply_tracking", + "zabbix_integration", + "qr_codes" + ] + }, + "settings": { + "zabbix_url": "", + "zabbix_token": "", + "supply_alert_threshold": 10, + "default_driver_source": "internal" + } +} diff --git a/plugins/printers/migrations/env.py b/plugins/printers/migrations/env.py new file mode 100644 index 00000000..3b88cd49 --- /dev/null +++ b/plugins/printers/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the printers plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_printers. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'printers' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/printers/migrations/script.py.mako b/plugins/printers/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/printers/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/printers/migrations/versions/0001_printers_anchor.py b/plugins/printers/migrations/versions/0001_printers_anchor.py new file mode 100644 index 00000000..75a12edc --- /dev/null +++ b/plugins/printers/migrations/versions/0001_printers_anchor.py @@ -0,0 +1,28 @@ +"""printers plugin anchor (ownership cutover). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_printers. From this +anchor forward, new printers schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'printers0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/printers/models/__init__.py b/plugins/printers/models/__init__.py new file mode 100644 index 00000000..0c64ef5b --- /dev/null +++ b/plugins/printers/models/__init__.py @@ -0,0 +1,20 @@ +"""Printers plugin models.""" + +from .printer import Printer, PrinterType # Asset-based models +from .printer_driver import PrinterDriver +from .model_supply import ( # data-driven model -> toner/drum/waste mapping + ModelSupply, + SUPPLY_TYPES, + SUPPLY_COLORS, + CAPACITY_TIERS, +) + +__all__ = [ + 'Printer', + 'PrinterType', + 'PrinterDriver', + 'ModelSupply', + 'SUPPLY_TYPES', + 'SUPPLY_COLORS', + 'CAPACITY_TIERS', +] diff --git a/plugins/printers/models/model_supply.py b/plugins/printers/models/model_supply.py new file mode 100644 index 00000000..05d7575c --- /dev/null +++ b/plugins/printers/models/model_supply.py @@ -0,0 +1,56 @@ +"""Model-to-supply mapping - data-driven toner/drum/waste part numbers. + +Replaces the old hardcoded part-number table. Each row maps one printer +model to one supply part (a toner of a given color and capacity tier, or a +drum/waste/maintenance item). Lets new models and their toners be added +through the API/UI without a code change. +""" + +from shopdb.api import db, BaseModel + + +# allowed values, surfaced to the UI via the /supplies/meta endpoint +SUPPLY_TYPES = ('toner', 'drum', 'waste', 'maintenance') +SUPPLY_COLORS = ('black', 'cyan', 'magenta', 'yellow', 'none') +CAPACITY_TIERS = ('standard', 'high', 'extrahigh', 'metered', 'dmo') + + +class ModelSupply(BaseModel): + """One supply part belonging to one printer model.""" + __tablename__ = 'modelsupplies' + + modelsupplyid = db.Column(db.Integer, primary_key=True) + + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=False, + ) + + # toner, drum, waste, maintenance + supplytype = db.Column(db.String(20), nullable=False, default='toner') + # black, cyan, magenta, yellow, or none (drum/waste have no color) + color = db.Column(db.String(20), nullable=False, default='none') + # standard, high, extrahigh, metered, dmo + capacitytier = db.Column(db.String(20), nullable=False, default='standard') + + partnumber = db.Column(db.String(50), nullable=False) + marketingname = db.Column(db.String(120)) + pageyield = db.Column(db.Integer, comment='Rated page yield at 5 percent coverage') + notes = db.Column(db.Text) + + model = db.relationship('Model', backref='supplies') + + # one part number per model, no duplicates + __table_args__ = ( + db.UniqueConstraint('modelnumberid', 'partnumber', name='uq_modelsupply_part'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + data = super().to_dict() + if self.model: + data['modelnumber'] = self.model.modelnumber + return data diff --git a/plugins/printers/models/printer.py b/plugins/printers/models/printer.py new file mode 100644 index 00000000..428a2f2b --- /dev/null +++ b/plugins/printers/models/printer.py @@ -0,0 +1,123 @@ +"""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') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class Printer(BaseModel): + """ + Printer-specific extension data (new Asset architecture). + + Links to core Asset table via assetid. + Stores printer-specific fields like type, Windows name, share name, etc. + """ + __tablename__ = 'printers' + + printerid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Printer classification + printertypeid = db.Column( + db.Integer, + db.ForeignKey('printertypes.printertypeid'), + nullable=True + ) + + # Vendor + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + comment='Network hostname' + ) + + # Windows/Network naming + windowsname = db.Column( + db.String(255), + comment='Windows printer name (e.g., \\\\server\\printer)' + ) + sharename = db.Column( + db.String(100), + comment='CSF/share name' + ) + + # Installation + iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer') + installpath = db.Column(db.String(255), comment='Driver install path') + + # Printer PIN (for secure print) + pin = db.Column(db.String(20)) + + # Features + iscolor = db.Column(db.Boolean, default=False, comment='Color capable') + isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable') + isnetwork = db.Column(db.Boolean, default=True, comment='Network connected') + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('printer', uselist=False, lazy='joined') + ) + printertype = db.relationship('PrinterType', backref='printers') + vendor = db.relationship('Vendor', backref='printer_items') + model = db.relationship('Model', backref='printer_items') + + __table_args__ = ( + db.Index('idx_printer_type', 'printertypeid'), + db.Index('idx_printer_hostname', 'hostname'), + db.Index('idx_printer_windowsname', 'windowsname'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.printertype: + result['printertypename'] = self.printertype.printertype + if self.vendor: + result['vendorname'] = self.vendor.vendor + if self.model: + result['modelname'] = self.model.modelnumber + if self.model.imageurl: + result['imageurl'] = self.model.imageurl + + return result diff --git a/plugins/printers/models/printer_driver.py b/plugins/printers/models/printer_driver.py new file mode 100644 index 00000000..acb4227e --- /dev/null +++ b/plugins/printers/models/printer_driver.py @@ -0,0 +1,33 @@ +"""Printer driver: a named link (SMB path or HTTP URL) to driver files.""" + +from shopdb.api import db + + +class PrinterDriver(db.Model): + __tablename__ = 'printerdrivers' + + driverid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), nullable=False) + # SMB path (\\\\server\\share\\...) or HTTP URL to the driver package + location = db.Column(db.String(500), nullable=False) + description = db.Column(db.Text) + # Optional: attach a driver to a specific printer model + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + isactive = db.Column(db.Boolean, default=True, nullable=False) + + model = db.relationship('Model') + + def to_dict(self): + return { + 'driverid': self.driverid, + 'name': self.name, + 'location': self.location, + 'description': self.description, + 'modelnumberid': self.modelnumberid, + 'modelname': self.model.modelnumber if self.model else None, + 'isactive': bool(self.isactive), + } diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py new file mode 100644 index 00000000..7198092a --- /dev/null +++ b/plugins/printers/plugin.py @@ -0,0 +1,241 @@ +"""Printers 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 Printer, PrinterType, ModelSupply, PrinterDriver +from .api import printers_asset_bp +from .services import ZabbixService + +logger = logging.getLogger(__name__) + + +class PrintersPlugin(BasePlugin): + """ + Printers plugin - manages printer assets. + + Supports both legacy Machine-based architecture and new Asset-based architecture: + - Legacy: PrinterData table linked to machines + - New: Printer table linked to assets + + Features: + - PrinterType classification + - Windows/network naming + - Zabbix integration for real-time supply level lookups + """ + + def __init__(self): + self._manifest = self._load_manifest() + self._zabbixservice = None + + 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', 'printers'), + version=self._manifest.get('version', '2.0.0'), + description=self._manifest.get( + 'description', + 'Printer management with Zabbix integration' + ), + 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/printers'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """ + Return Flask Blueprint with API routes. + + Returns the new Asset-based blueprint. + Legacy Machine-based blueprint is registered separately in init_app. + """ + return printers_asset_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [ + Printer, # Asset-based + PrinterType, # printer type classification + PrinterDriver, # driver links (SMB/HTTP) + ModelSupply, # model -> toner/drum/waste part numbers + ] + + def get_services(self) -> Dict[str, Type]: + """Return plugin services.""" + return { + 'zabbix': ZabbixService, + } + + @property + def zabbixservice(self) -> ZabbixService: + """Get Zabbix service instance.""" + if self._zabbixservice is None: + self._zabbixservice = ZabbixService() + return self._zabbixservice + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + app.config.setdefault('ZABBIX_URL', '') + app.config.setdefault('ZABBIX_TOKEN', '') + + logger.info(f"Printers 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_printer_types() + logger.info("Printers plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure printer asset type exists.""" + existing = AssetType.query.filter_by(assettype='printer').first() + if not existing: + at = AssetType( + assettype='printer', + pluginname='printers', + tablename='printers', + description='Printers (laser, inkjet, label, MFP, plotter)', + icon='printer' + ) + db.session.add(at) + logger.debug("Created asset type: printer") + db.session.commit() + + def _ensure_printer_types(self) -> None: + """Ensure basic printer types exist (new architecture).""" + printer_types = [ + ('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'), + ('Dot Matrix', 'Dot matrix printer', 'th'), + ('Other', 'Other printer type', 'printer'), + ] + + for name, description, icon in printer_types: + existing = PrinterType.query.filter_by(printertype=name).first() + if not existing: + pt = PrinterType( + printertype=name, + description=description, + icon=icon + ) + db.session.add(pt) + logger.debug(f"Created printer type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Printers plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('printers') + def printerscli(): + """Printers plugin commands.""" + pass + + @printerscli.command('check-supplies') + @click.argument('ip') + def checksupplies(ip): + """Check supply levels for a printer by IP (via Zabbix).""" + from flask import current_app + + with current_app.app_context(): + service = ZabbixService() + + if not service.isconfigured: + click.echo('Error: Zabbix not configured. Set ZABBIX_URL and ZABBIX_TOKEN.') + return + + supplies = service.getsuppliesbyip(ip) + if not supplies: + click.echo(f'No supply data found for {ip}') + return + + click.echo(f'Supply levels for {ip}:') + 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]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Printer Status', + 'component': 'PrinterStatusWidget', + 'endpoint': '/api/printers/dashboard/summary', + 'size': 'medium', + 'position': 10, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Printers', + 'icon': 'printer', + 'route': '/printers', + 'position': 20, + }, + ] + + def get_reports(self) -> List[Dict]: + """Return report card definitions for the Reports hub.""" + return [ + { + 'id': 'toner', + 'name': 'Toner Report', + 'description': 'Printers with low or critical toner/supply levels', + 'category': 'printers', + 'route': '/reports/toner', + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('printers.view', 'View printers', 'printers'), + ('printers.create', 'Create printers', 'printers'), + ('printers.edit', 'Edit printers', 'printers'), + ('printers.delete', 'Delete printers', 'printers'), + ] diff --git a/plugins/printers/schemas/__init__.py b/plugins/printers/schemas/__init__.py new file mode 100644 index 00000000..2d84390d --- /dev/null +++ b/plugins/printers/schemas/__init__.py @@ -0,0 +1 @@ +"""Printers plugin schemas (for future Marshmallow serialization).""" diff --git a/plugins/printers/services/__init__.py b/plugins/printers/services/__init__.py new file mode 100644 index 00000000..6a78fa9c --- /dev/null +++ b/plugins/printers/services/__init__.py @@ -0,0 +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', + 'classifysupply', + 'derivesupplytype', + 'derivecolor', + 'lookupsupplies', + 'seedsupplies', +] diff --git a/plugins/printers/services/seed_supplies.py b/plugins/printers/services/seed_supplies.py new file mode 100644 index 00000000..f29c103b --- /dev/null +++ b/plugins/printers/services/seed_supplies.py @@ -0,0 +1,358 @@ +"""Seed data for model -> supply mappings. + +Corrected against official HP and Xerox sources (verification pass +2026-06-25). Replaces the old hardcoded supply_parts table, which had +roughly ten wrong part numbers, scrambled colors, and HP high-yield +cartridges mislabeled as metered. + +Each family matches printer models by substring (matchkeys), the same way +the classic ASP report did, then attaches its supplies. seedsupplies() +finds or creates the vendor and a canonical model row, then inserts any +missing supply rows. Re-running is safe: existing part numbers are skipped. + +Key facts encoded here: + - HP "X" = high yield, NOT metered. HP metered cartridges are contractual + "C-suffix" SKUs and exist only on Enterprise/Managed hardware, so the + Pro color families (M454/M479, M251/M252/M277, M254/M255) have none. + - Xerox uses distinct sold / metered / dmo part numbers per color. +""" + +import logging + +from shopdb.api import db, Vendor, Model + +from ..models import ModelSupply + +logger = logging.getLogger(__name__) + + +def _toner(color, tier, partnumber, marketingname, pageyield=None): + return { + 'supplytype': 'toner', 'color': color, 'capacitytier': tier, + 'partnumber': partnumber, 'marketingname': marketingname, + 'pageyield': pageyield, + } + + +def _part(supplytype, partnumber, marketingname, notes=None): + return { + 'supplytype': supplytype, 'color': 'none', 'capacitytier': 'standard', + 'partnumber': partnumber, 'marketingname': marketingname, + 'pageyield': None, 'notes': notes, + } + + +# Corrected supply catalog. Each entry: vendor, canonical model name, +# substring matchkeys, and the list of supplies. +SEED = [ + # ----- HP color, Pro (no metered variant exists) ----- + { + 'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M454 / M479', + 'matchkeys': ['M454', 'M479'], + 'supplies': [ + _toner('black', 'standard', 'W2020A', '414A Black', 2400), + _toner('black', 'high', 'W2020X', '414X Black', 7500), + _toner('cyan', 'standard', 'W2021A', '414A Cyan', 2100), + _toner('cyan', 'high', 'W2021X', '414X Cyan', 6000), + _toner('yellow', 'standard', 'W2022A', '414A Yellow', 2100), + _toner('yellow', 'high', 'W2022X', '414X Yellow', 6000), + _toner('magenta', 'standard', 'W2023A', '414A Magenta', 2100), + _toner('magenta', 'high', 'W2023X', '414X Magenta', 6000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M251 / M252 / M277', + 'matchkeys': ['M251', 'M252', 'M277', 'M274'], + 'supplies': [ + _toner('black', 'standard', 'CF400A', '201A Black', 1500), + _toner('black', 'high', 'CF400X', '201X Black', 2800), + _toner('cyan', 'standard', 'CF401A', '201A Cyan', 1400), + _toner('cyan', 'high', 'CF401X', '201X Cyan', 2500), + _toner('yellow', 'standard', 'CF402A', '201A Yellow', 1400), + _toner('yellow', 'high', 'CF402X', '201X Yellow', 2500), + _toner('magenta', 'standard', 'CF403A', '201A Magenta', 1400), + _toner('magenta', 'high', 'CF403X', '201X Magenta', 2500), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M254 / M255', + 'matchkeys': ['M254', 'M255', 'M280', 'M281'], + 'supplies': [ + _toner('black', 'standard', 'CF500A', '202A Black', 1400), + _toner('black', 'high', 'CF500X', '202X Black', 3200), + _toner('cyan', 'standard', 'CF501A', '202A Cyan', 1300), + _toner('cyan', 'high', 'CF501X', '202X Cyan', 2500), + _toner('yellow', 'standard', 'CF502A', '202A Yellow', 1300), + _toner('yellow', 'high', 'CF502X', '202X Yellow', 2500), + _toner('magenta', 'standard', 'CF503A', '202A Magenta', 1300), + _toner('magenta', 'high', 'CF503X', '202X Magenta', 2500), + ], + }, + # ----- HP mono (X = high yield; XC/YC = contractual metered) ----- + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M404 / M406 / M428 / M430', + 'matchkeys': ['M404', 'M406', 'M428', 'M430'], + 'supplies': [ + _toner('black', 'standard', 'CF258A', '58A Black', 3000), + _toner('black', 'high', 'CF258X', '58X Black', 10000), + _toner('black', 'metered', 'CF258XC', '58X Black (Contract)', 10000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M607 / M608 / M609 / M631 / M632 / M633', + 'matchkeys': ['M607', 'M608', 'M609', 'M631', 'M632', 'M633'], + 'supplies': [ + _toner('black', 'standard', 'CF237A', '37A Black', 11000), + _toner('black', 'high', 'CF237X', '37X Black', 25000), + # 37Y extra-high does NOT fit the M607 + _toner('black', 'extrahigh', 'CF237Y', '37Y Black (not M607)', 41000), + _toner('black', 'metered', 'CF237YC', '37Y Black (Contract)', 41000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M506 / M527 / M501', + 'matchkeys': ['M506', 'M527', 'M501'], + 'supplies': [ + _toner('black', 'standard', 'CF287A', '87A Black', 9000), + _toner('black', 'high', 'CF287X', '87X Black', 18000), + _toner('black', 'metered', 'CF287XC', '87X Black (Contract)', 18000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M602 / M603 / M4555', + 'matchkeys': ['M602', 'M603', 'M4555'], + 'supplies': [ + _toner('black', 'standard', 'CE390A', '90A Black', 10000), + # 90X does NOT fit the M601 + _toner('black', 'high', 'CE390X', '90X Black (not M601)', 24000), + _toner('black', 'metered', 'CE390XC', '90X Black (Contract)', 24000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet P3015 / M521 / M525', + 'matchkeys': ['P3015', 'M521', 'M525'], + 'supplies': [ + _toner('black', 'standard', 'CE255A', '55A Black', 6000), + _toner('black', 'high', 'CE255X', '55X Black', 12500), + _toner('black', 'metered', 'CE255XC', '55X Black (Contract)', 12500), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet 4250 / 4350', + 'matchkeys': ['4250', '4350'], + 'supplies': [ + _toner('black', 'standard', 'Q5942A', '42A Black', 10000), + _toner('black', 'high', 'Q5942X', '42X Black', 20000), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet M610 / M611 / M612 / M634 / M635 / M636', + 'matchkeys': ['M610', 'M611', 'M612', 'M634', 'M635', 'M636'], + 'supplies': [ + _toner('black', 'standard', 'W1470A', '147A Black', 10500), + _toner('black', 'high', 'W1470X', '147X Black', 25200), + ], + }, + { + 'vendor': 'HP', 'canonical': 'HP LaserJet Pro 4001 / 4101 (148 series)', + 'matchkeys': ['4001', '4101', '4002', '4102'], + 'supplies': [ + _toner('black', 'high', 'W1480X', '148X Black', 9500), + _toner('black', 'metered', 'W1020XC', '148 Black (Contract)', 9500), + ], + }, + + # ----- Xerox VersaLink color ----- + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C415', + 'matchkeys': ['C415'], + 'supplies': [ + _toner('black', 'standard', '006R04677', 'C415 Black', 2400), + _toner('cyan', 'standard', '006R04678', 'C415 Cyan', 2000), + _toner('magenta', 'standard', '006R04679', 'C415 Magenta', 2000), + _toner('yellow', 'standard', '006R04680', 'C415 Yellow', 2000), + _toner('black', 'high', '006R04685', 'C415 Black (High)', 10500), + _toner('cyan', 'high', '006R04686', 'C415 Cyan (High)', 7000), + _toner('magenta', 'high', '006R04687', 'C415 Magenta (High)', 7000), + _toner('yellow', 'high', '006R04688', 'C415 Yellow (High)', 7000), + _toner('black', 'metered', '006R04693', 'C415 Black (Metered)', 15000), + _toner('cyan', 'metered', '006R04694', 'C415 Cyan (Metered)', 10000), + _toner('magenta', 'metered', '006R04695', 'C415 Magenta (Metered)', 10000), + _toner('yellow', 'metered', '006R04696', 'C415 Yellow (Metered)', 10000), + _part('drum', '013R00701', 'C415 Drum / Imaging Unit'), + _part('waste', '008R13325', 'C415 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C405', + 'matchkeys': ['C405', 'C400'], + 'supplies': [ + _toner('black', 'standard', '106R03500', 'C405 Black', 2500), + _toner('yellow', 'standard', '106R03501', 'C405 Yellow', 2500), + _toner('cyan', 'standard', '106R03502', 'C405 Cyan', 2500), + _toner('magenta', 'standard', '106R03503', 'C405 Magenta', 2500), + _toner('black', 'high', '106R03512', 'C405 Black (High)', 5000), + _toner('black', 'extrahigh', '106R03524', 'C405 Black (Extra High)', 10500), + _part('drum', '108R01121', 'C400 / C405 Drum'), + _part('waste', '108R01124', 'C400 / C405 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C7100 / C7120 / C7125 / C7130', + 'matchkeys': ['C7100', 'C7120', 'C7125', 'C7130'], + 'supplies': [ + _toner('black', 'standard', '006R01824', 'C7125 Black (Sold)', 31300), + _toner('cyan', 'standard', '006R01825', 'C7125 Cyan (Sold)', 18500), + _toner('magenta', 'standard', '006R01826', 'C7125 Magenta (Sold)', 18500), + _toner('yellow', 'standard', '006R01827', 'C7125 Yellow (Sold)', 18500), + _toner('black', 'metered', '006R01820', 'C7125 Black (Metered)', 22500), + _toner('cyan', 'metered', '006R01821', 'C7125 Cyan (Metered)', 15500), + _toner('magenta', 'metered', '006R01822', 'C7125 Magenta (Metered)', 15500), + _toner('yellow', 'metered', '006R01823', 'C7125 Yellow (Metered)', 15500), + _part('drum', '013R00688', 'C7125 Drum'), + _part('waste', '115R00129', 'C7000 / C7100 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink B7100 / B7125 / B7130 / B7135', + 'matchkeys': ['B7100', 'B7125', 'B7130', 'B7135'], + 'supplies': [ + _toner('black', 'standard', '006R01818', 'B7125 Black (Sold/High)', 34300), + _toner('black', 'metered', '006R01817', 'B7125 Black (Metered)', 34300), + _toner('black', 'dmo', '006R01819', 'B7125 Black (DMO)', 34300), + _part('drum', '013R00687', 'B7125 Drum'), + _part('waste', '115R00129', 'B7000 / B7100 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', 'canonical': 'Xerox VersaLink B400 / B405', + 'matchkeys': ['B400', 'B405'], + 'supplies': [ + _toner('black', 'standard', '106R03580', 'B405 Black', 5900), + _toner('black', 'high', '106R03582', 'B405 Black (High)', 13900), + _toner('black', 'extrahigh', '106R03584', 'B405 Black (Extra High)', 24600), + _toner('black', 'metered', '106R03586', 'B405 Black (Metered)', 24600), + _part('drum', '101R00554', 'B400 / B405 Drum'), + # note: B405 has no waste cartridge + ], + }, + + # ----- Xerox AltaLink color ----- + { + 'vendor': 'Xerox', 'canonical': 'Xerox AltaLink C8130 / C8135 / C8145 / C8155 / C8170', + 'matchkeys': ['C8130', 'C8135', 'C8145', 'C8155', 'C8170'], + 'supplies': [ + _toner('black', 'standard', '006R01746', 'C8135 Black (Sold)'), + _toner('cyan', 'standard', '006R01747', 'C8135 Cyan (Sold)'), + _toner('magenta', 'standard', '006R01748', 'C8135 Magenta (Sold)'), + _toner('yellow', 'standard', '006R01749', 'C8135 Yellow (Sold)'), + _toner('black', 'metered', '006R01742', 'C8135 Black (Metered)'), + _toner('cyan', 'metered', '006R01743', 'C8135 Cyan (Metered)'), + _toner('magenta', 'metered', '006R01744', 'C8135 Magenta (Metered)'), + _toner('yellow', 'metered', '006R01745', 'C8135 Yellow (Metered)'), + _part('drum', '013R00681', 'C8135 Drum'), + _part('waste', '008R08101', 'C8135 Waste Cartridge'), + ], + }, + { + 'vendor': 'Xerox', + 'canonical': 'Xerox AltaLink C8030 / C8035 / C8045 / C8055 / EC8036', + 'matchkeys': ['C8030', 'C8035', 'C8045', 'C8055', 'EC8036', 'EC8056', 'C8036'], + 'supplies': [ + _toner('black', 'standard', '006R01697', 'C8030 Black (Sold)'), + _toner('cyan', 'standard', '006R01698', 'C8030 Cyan (Sold)'), + _toner('magenta', 'standard', '006R01699', 'C8030 Magenta (Sold)'), + _toner('yellow', 'standard', '006R01700', 'C8030 Yellow (Sold)'), + _toner('black', 'metered', '006R01701', 'C8030 Black (Metered)'), + _toner('cyan', 'metered', '006R01702', 'C8030 Cyan (Metered)'), + _toner('magenta', 'metered', '006R01703', 'C8030 Magenta (Metered)'), + _toner('yellow', 'metered', '006R01704', 'C8030 Yellow (Metered)'), + # legacy WorkCentre 78xx cross-reference set, still compatible + _toner('black', 'high', '006R01509', 'WC7800 Black (legacy)'), + _toner('yellow', 'high', '006R01510', 'WC7800 Yellow (legacy)'), + _toner('magenta', 'high', '006R01511', 'WC7800 Magenta (legacy)'), + _toner('cyan', 'high', '006R01512', 'WC7800 Cyan (legacy)'), + _part('drum', '013R00662', 'C8030 / EC8036 Drum'), + _part('waste', '008R13061', 'C8030 / EC8036 Waste Cartridge'), + ], + }, +] + + +def _find_or_create_vendor(name): + vendor = Vendor.query.filter_by(vendor=name).first() + if not vendor: + vendor = Vendor(vendor=name) + db.session.add(vendor) + db.session.flush() + return vendor + + +def _matching_models(matchkeys, vendorid): + """Existing models whose modelnumber contains any matchkey (this vendor).""" + found = [] + for key in matchkeys: + rows = Model.query.filter( + Model.vendorid == vendorid, + Model.modelnumber.ilike(f'%{key}%'), + ).all() + for row in rows: + if row not in found: + found.append(row) + return found + + +def seedsupplies(): + """Seed corrected model->supply data. Idempotent. + + Returns a summary dict with counts. Attaches supplies to every existing + model that matches a family's keys; if a family matches no existing model, + creates a canonical model row so its toners are still available. + """ + models_touched = 0 + supplies_added = 0 + + for family in SEED: + vendor = _find_or_create_vendor(family['vendor']) + targets = _matching_models(family['matchkeys'], vendor.vendorid) + + if not targets: + # modeltypeid is a legacy Model column (nullable); printers are + # asset-based now and carry their type via PrinterType, not here. + model = Model( + modelnumber=family['canonical'], + vendorid=vendor.vendorid, + ) + db.session.add(model) + db.session.flush() + targets = [model] + + for model in targets: + models_touched += 1 + existing = { + supply.partnumber + for supply in ModelSupply.query.filter_by( + modelnumberid=model.modelnumberid + ).all() + } + for spec in family['supplies']: + if spec['partnumber'] in existing: + continue + db.session.add(ModelSupply( + modelnumberid=model.modelnumberid, + supplytype=spec['supplytype'], + color=spec['color'], + capacitytier=spec['capacitytier'], + partnumber=spec['partnumber'], + marketingname=spec.get('marketingname'), + pageyield=spec.get('pageyield'), + notes=spec.get('notes'), + )) + supplies_added += 1 + + db.session.commit() + logger.info( + "Seeded printer supplies: %d models touched, %d supplies added", + models_touched, supplies_added + ) + return {'modelstouched': models_touched, 'suppliesadded': supplies_added} diff --git a/plugins/printers/services/supply_parts.py b/plugins/printers/services/supply_parts.py new file mode 100644 index 00000000..178b109a --- /dev/null +++ b/plugins/printers/services/supply_parts.py @@ -0,0 +1,107 @@ +"""Printer supply classification and part-number lookup. + +Part numbers now live in the modelsupplies table (see seed_supplies.py), +managed through the API/UI. This module keeps the runtime logic that is not +per-model data: classifying a reported level into ok/low/critical (waste +cartridges invert), deriving supply type and color from a Zabbix item name, +and reading the matching part numbers out of the database. +""" + +from typing import Dict, List, Optional + + +# alert thresholds (percent remaining) +CRITICAL_THRESHOLD = 5 +LOW_THRESHOLD = 10 + + +def derivesupplytype(name: str) -> str: + """Map a Zabbix item name to a supply type.""" + lowername = (name or "").lower() + if "waste" in lowername: + return "waste" + if "drum" in lowername or "imaging" in lowername: + return "drum" + if "maintenance" in lowername or "fuser" in lowername: + return "maintenance" + return "toner" + + +def derivecolor(name: str, tagcolor: Optional[str] = None) -> str: + """Best-effort supply color from a Zabbix color tag, then the item name.""" + color = (tagcolor or "").lower() + if "black" in color: + return "black" + if color in ("cyan", "magenta", "yellow"): + return color + if color in ("grey", "gray"): + return "gray" + + lowername = (name or "").lower() + for candidate in ("cyan", "magenta", "yellow", "black"): + if candidate in lowername: + return candidate + return "none" + + +def classifysupply(level: float, name: str, vendor: Optional[str]) -> Dict: + """Classify one supply item into ok/low/critical. + + Waste cartridge fill is inverted vs a toner level: a full waste cartridge + is bad. Standard vendors report waste as percent FULL (high = bad). Xerox + EC/AltaLink series report waste as percent capacity REMAINING (low = bad), + same direction as toner. Normalise everything to percent remaining first. + """ + lowername = (name or "").lower() + iswaste = "waste" in lowername + isdrum = "drum" in lowername or "imaging" in lowername + isxerox = bool(vendor) and "xerox" in vendor.lower() + + if iswaste and not isxerox: + remaining = 100 - level + else: + remaining = level + + if remaining <= CRITICAL_THRESHOLD: + status = "critical" + elif remaining <= LOW_THRESHOLD: + status = "low" + else: + status = "ok" + + return { + "status": status, + "remaining": round(remaining, 1), + "iswaste": iswaste, + "isdrum": isdrum, + } + + +def lookupsupplies(modelnumberid: Optional[int], color: str, + supplytype: str) -> List[Dict]: + """Part-number options for a model + color + supply type, from the DB. + + Returns every matching capacity tier (standard / high / metered / ...) so + the report can show all reorder options, like the classic report did. + """ + if not modelnumberid: + return [] + + from ..models import ModelSupply + + query = ModelSupply.query.filter_by( + modelnumberid=modelnumberid, + supplytype=supplytype, + isactive=True, + ) + # toners are color-specific; drum/waste/maintenance are not + if supplytype == 'toner' and color and color != 'none': + query = query.filter_by(color=color) + + rows = query.order_by(ModelSupply.capacitytier).all() + return [{ + 'partnumber': row.partnumber, + 'marketingname': row.marketingname, + 'capacitytier': row.capacitytier, + 'pageyield': row.pageyield, + } for row in rows] diff --git a/plugins/printers/services/zabbix_service.py b/plugins/printers/services/zabbix_service.py new file mode 100644 index 00000000..ddd8e603 --- /dev/null +++ b/plugins/printers/services/zabbix_service.py @@ -0,0 +1,254 @@ +"""Zabbix service for real-time printer supply lookups. + +Ports the classic ASP shopdb Zabbix integration (includes/zabbix.asp and +includes/zabbix_all_supplies.asp) to Python. Key behaviours preserved from +the live integration: + + - Auth via an Authorization: Bearer header (Zabbix 6.0+ / 7.0). + The old payload "auth" field is rejected by Zabbix 7.0. + - Hosts are named by IP address, so a host is located with + host.get filter {host: [ip]}, not by interface address. + - Supply levels come from items tagged component=supplies AND type=level, + not from a key_ substring search. + - Each level item carries a color tag used for display and part lookup. + +Configuration (database Setting overrides env var): + ZABBIX_ENABLED: turn the integration on + ZABBIX_URL: base URL or full api_jsonrpc.php URL + ZABBIX_TOKEN: API token +""" + +import logging +from typing import Dict, List, Optional + +import requests +from flask import current_app + +from shopdb.api import cache + +logger = logging.getLogger(__name__) + + +class ZabbixService: + """Zabbix API client for printer supply and ping lookups.""" + + CACHE_TTL = 300 # 5 min, matches the classic Application cache + REACHABLE_CHECK_TTL = 60 + + # 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) + + # 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 + + # -- configuration ------------------------------------------------------- + + @property + def isenabled(self) -> bool: + """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) + return current_app.config.get('ZABBIX_ENABLED', False) + + @property + def isconfigured(self) -> bool: + """Enabled, and a URL plus token are present.""" + if not self.isenabled: + return False + 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: + """Cheap connectivity probe, cached for 60s.""" + if not self.isconfigured: + return False + + cache_key = 'zabbix_reachable' + cached = cache.get(cache_key) + if cached is not None: + return cached + + try: + 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("Zabbix reachability: %s", reachable) + return reachable + + # -- 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, + "id": 1, + } + headers = { + "Content-Type": "application/json-rpc", + "Authorization": f"Bearer {self._token}", + } + + try: + response = requests.post( + self.endpoint, + json=payload, + headers=headers, + timeout=self.API_TIMEOUT, + ) + response.raise_for_status() + data = response.json() + except (requests.RequestException, ValueError) as exc: + logger.error("Zabbix %s call failed: %s", method, exc) + return None + + 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].get("hostid") + return None + + def _extract_color(self, item: Dict) -> str: + """Pull and normalise the color tag, falling back to the item name.""" + color = "" + for tag in item.get("tags", []) or []: + if tag.get("tag") == "color": + color = (tag.get("value") or "").lower() + break + if "black" in color: + color = "black" + elif color in ("grey", "gray"): + color = "gray" + + if not color: + name = (item.get("name") or "").lower() + for candidate in ("cyan", "magenta", "yellow", "black"): + if candidate in name: + color = candidate + break + if not color and ("gray" in name or "grey" in name): + color = "gray" + return color + + def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]: + """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. + """ + hostid = self.gethostidbyip(ip) + if not hostid: + logger.debug("No Zabbix host for IP %s", ip) + return None + + 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: + 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))) + except (ValueError, TypeError): + level = 0 + supplies.append({ + "name": item.get("name", "Unknown"), + "level": level, + "color": self._extract_color(item), + "itemid": item.get("itemid"), + }) + return supplies + + 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]]: + """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): + """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("zabbix_reachable") diff --git a/plugins/slides/PLAN-slide-manager.md b/plugins/slides/PLAN-slide-manager.md new file mode 100644 index 00000000..43bbb46c --- /dev/null +++ b/plugins/slides/PLAN-slide-manager.md @@ -0,0 +1,132 @@ +# Plan: Slides plugin -> full slide manager (Flask) + +Port the classic-ASP slide manager (tv-dashboard/slidemanager.asp + apislides.asp) +into the shopdb-flask `slides` plugin. Two surfaces (lobby display, shopfloor +screensaver), upload / reorder / delete, consumed by the lobby TV dashboard and +the EventSaver screensaver over HTTP. + +Existing plugin is read-only (single-folder GET). This extends it to full CRUD + +per-surface + a management UI. + +## Decisions (locked for this plan) + +- SURFACES: fixed allowlist `lobby`, `shopfloor` (mirrors the ASP allowlist). + Not user-defined - keeps validation + paths simple. +- STORAGE: image FILES on disk, ORDER/metadata in the DB. + - Files: `instance/slides//` (outside static; served via a + plugin route so we control content-type + path-traversal, like slide.asp). + - Metadata: one table (order + seconds + surface). Files are the source of + truth for existence; DB rows that lost their file are ignored + pruned + (same self-healing as GetOrderList in the ASP lib). + - Rationale: native, simple, no BLOBs. BLOB-in-DB is the fallback only if the + deploy host cannot give Flask a writable volume. +- MIGRATION: add the table to the CORE alembic chain (ADR-004 / 7c04 - + single authoritative chain, no per-plugin chain). +- AUTH: admin/CRUD routes JWT-protected. The FEED + image-serve routes are + PUBLIC (the screensaver + lobby kiosks have no auth - matches apislides.asp). +- NAMING: locked convention v1 - lowercase concatenated table/columns; + Python/JS vars mirror column names exactly. + +## Data model (core migration) + +Table `tvslides` (new, added to core chain): + +| column | type | notes | +|--------------|--------------|-----------------------------------------| +| slideid | int PK | | +| surface | varchar(20) | 'lobby' | 'shopfloor' (indexed) | +| filename | varchar(255) | safe basename, unique per surface | +| sortorder | int | play order within surface | +| seconds | int | 0 = use default interval | +| uploadeddate | datetime | | + +Unique (surface, filename). Model lives in `plugins/slides/models/tvslide.py`, +imported via `shopdb.api` surface only (contract purity). Registered in the core +migration chain per ADR-004. + +## Backend - plugins/slides/ + +``` +plugins/slides/ + plugin.py # SlidesPlugin: get_blueprint, get_models, get_navigation_items, on_install + manifest.json # api_prefix /api/slides, provides slideshow + slidemanager + models/tvslide.py # TvSlide model + api/routes.py # blueprint slides_bp +``` + +Routes (prefix `/api/slides`): + +- PUBLIC (no auth) - consumed by screensaver + lobby: + - `GET /feed?surface=lobby|shopfloor` + -> flat shape the EventSaver .scr + lobby expect: + `{success, surface, basepath, interval, slides:[{filename, seconds}]}` + basepath -> the image route below. (Deliberately NOT the success_response + {data:{}} wrapper, so the .scr parser needs no change.) + - `GET /img//` -> serve the image (content-type + + path-traversal guard; send_file from instance/slides//). +- ADMIN (JWT) - consumed by the Vue manager: + - `POST //upload` -> request.files (native multipart), save + + create TvSlide rows at end of order. Skips non-images. Unique-renames. + - `POST //order` -> body {order:[filename,...]} rewrite sortorder, + preserve seconds. + - `POST //delete` -> body {files:[...]} delete file + row (multi). + - `PATCH //` -> {seconds} (kept server-side; UI hidden for now). + - `GET /` -> admin list (ordered, with slideid) for the UI. + +Ordering/natural-sort: order comes from `sortorder`; newly-uploaded files append +in natural (numeric-aware) filename order so Slide1..Slide11 land right (port the +ASP NatKey). + +## Frontend - core (no frontend plugin system) + +Vue pages live in core `frontend/src/` (per project note - plugins own backend, +Vue pages are core). Add: + +- `frontend/src/views/SlideManager.vue` + - Surface tabs (Lobby Display / Shopfloor Screensaver). + - Upload (file input -> POST /upload), thumbnail grid/table. + - Drag reorder via vuedraggable -> POST /order. + - Checkbox multi-select + Delete Selected -> POST /delete. + - Inherits AppLayout (sidebar + topbar + theme) automatically -> matches the + site with ZERO styling work (the whole reason to move off ASP). +- Router entry in `frontend/src/router` (e.g. /slides), guarded (admin). +- Nav: plugin `get_navigation_items()` returns the sidebar entry; the frontend + nav consumes plugin nav hooks (as knowledgebase/notifications do). +- `frontend/src/api/slidesApi.js` wrapper for the admin calls. + +## Consumers + +- Lobby: `TVDashboard.vue` already hits /api/slides; repoint at `/feed?surface=lobby`. +- Screensaver: change `EventSaver.ini` `url=` to + `https:///api/slides/feed?surface=shopfloor`, rehash the ini + + bump the manifest DetectionValue. The .scr HTTP mode is unchanged because + /feed returns the flat shape it already parses. + +## Tests (pytest, per plugin conventions) + +- test_plugins/test_slides.py: upload (fake file) creates rows + file; feed + returns ordered flat shape; order persists + preserves seconds; delete removes + file+row; surface allowlist rejects junk; path-traversal guard on /img; + natural sort on append; feed is public / admin routes require JWT. +- Guard test already enforces contract-only imports. + +## Phasing + +1. Model + core migration + manifest bump. +2. Backend routes (feed + img public; upload/order/delete/list admin) + tests. +3. Vue SlideManager.vue + router + nav + slidesApi.js. +4. Repoint TVDashboard.vue to /feed?surface=lobby. +5. Cut over screensaver: EventSaver.ini url -> Flask /feed, rehash + manifest. +6. Retire classic ASP tv-dashboard slide pages once Flask is live for this site. + +## Open decisions / gates + +- DEPLOY GATE: shopdb-flask must be deployed + reachable by the screensaver PCs + and lobby, with a writable `instance/slides` volume. Live shopdb is still the + classic ASP box; Flask prod target is docker, not yet deployed here. This plan + is buildable now but not cutover-able until Flask is live. +- Image serving: via a plugin route (send_file, guarded) vs Flask static. Route + chosen for the traversal guard + content-type control (parity with slide.asp). +- Seconds field: model + PATCH kept; UI control hidden for now (matches the ASP + decision to hide per-slide delay). +- BLOB fallback: only if the host denies a writable slides volume. diff --git a/plugins/slides/__init__.py b/plugins/slides/__init__.py new file mode 100644 index 00000000..86e87873 --- /dev/null +++ b/plugins/slides/__init__.py @@ -0,0 +1,5 @@ +"""Slides plugin package.""" + +from .plugin import SlidesPlugin + +__all__ = ['SlidesPlugin'] diff --git a/plugins/slides/api/__init__.py b/plugins/slides/api/__init__.py new file mode 100644 index 00000000..74cc3489 --- /dev/null +++ b/plugins/slides/api/__init__.py @@ -0,0 +1,5 @@ +"""Slides plugin API.""" + +from .routes import slides_bp + +__all__ = ['slides_bp'] diff --git a/plugins/slides/api/routes.py b/plugins/slides/api/routes.py new file mode 100644 index 00000000..d61ef70e --- /dev/null +++ b/plugins/slides/api/routes.py @@ -0,0 +1,210 @@ +"""Slides API: lobby-display + shopfloor-screensaver slideshows. + +Image files live on disk at instance/slides//; TvSlide rows hold play +order + per-slide seconds. Feed + image routes are PUBLIC (kiosks/screensaver +have no auth); management routes are admin-only. +""" + +import os +import re + +from flask import Blueprint, request, current_app, jsonify, send_from_directory +from flask_jwt_extended import jwt_required +from werkzeug.utils import secure_filename + +from shopdb.api import db, success_response, error_response, ErrorCodes, require_role + +from ..models import TvSlide + +slides_bp = Blueprint('slides', __name__) + +SURFACES = frozenset({'lobby', 'shopfloor'}) +VALID_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'} +DEFAULT_INTERVAL = 10 # seconds per slide when a slide's own seconds is 0 + + +def _surface_dir(surface): + return os.path.join(current_app.instance_path, 'slides', surface) + + +def _natkey(name): + """Natural sort key so Slide1..Slide11 order numerically, not lexically.""" + return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', name)] + + +def _valid_surface(surface): + return surface in SURFACES + + +def _is_image(filename): + return os.path.splitext(filename)[1].lower() in VALID_EXTENSIONS + + +# ============================================================================= +# PUBLIC - consumed by the lobby dashboard + the screensaver +# ============================================================================= + +@slides_bp.route('/feed', methods=['GET']) +def feed(): + """Flat playlist for a surface. NOT wrapped in the success_response envelope + so the screensaver's existing parser needs no change.""" + surface = request.args.get('surface', 'lobby') + if not _valid_surface(surface): + surface = 'lobby' + directory = _surface_dir(surface) + rows = (TvSlide.query.filter_by(surface=surface) + .order_by(TvSlide.sortorder, TvSlide.slideid).all()) + slides = [ + {'filename': r.filename, 'seconds': r.seconds or DEFAULT_INTERVAL} + for r in rows + if os.path.isfile(os.path.join(directory, r.filename)) + ] + return jsonify({ + 'success': True, + 'surface': surface, + 'basepath': f'/api/slides/img/{surface}/', + 'interval': DEFAULT_INTERVAL, + 'slides': slides, + }) + + +@slides_bp.route('/img//', methods=['GET']) +def serve_image(surface, filename): + """Serve a slide image with a path-traversal guard.""" + if not _valid_surface(surface): + return error_response(ErrorCodes.NOT_FOUND, 'Unknown surface', http_code=404) + safe = os.path.basename(filename) + if safe != filename or not safe: + return error_response(ErrorCodes.NOT_FOUND, 'Invalid filename', http_code=404) + directory = _surface_dir(surface) + if not os.path.isfile(os.path.join(directory, safe)): + return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404) + return send_from_directory(directory, safe) + + +# ============================================================================= +# ADMIN - the Vue slide manager +# ============================================================================= + +@slides_bp.route('/', methods=['GET']) +@jwt_required() +@require_role('admin') +def list_slides(surface): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + directory = _surface_dir(surface) + rows = (TvSlide.query.filter_by(surface=surface) + .order_by(TvSlide.sortorder, TvSlide.slideid).all()) + out = [] + for r in rows: + if os.path.isfile(os.path.join(directory, r.filename)): + item = r.to_dict() + item['url'] = f'/api/slides/img/{surface}/{r.filename}' + out.append(item) + return success_response(out) + + +@slides_bp.route('//upload', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_slides(surface): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + + files = request.files.getlist('files') + if not files and 'file' in request.files: + files = [request.files['file']] + if not files: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No files uploaded') + + directory = _surface_dir(surface) + os.makedirs(directory, exist_ok=True) + + # Append after the current max order; upload in natural filename order. + maxorder = db.session.query(db.func.max(TvSlide.sortorder)).filter_by(surface=surface).scalar() + order = (maxorder or 0) + existing = {r.filename for r in TvSlide.query.filter_by(surface=surface).all()} + + added = [] + for f in sorted(files, key=lambda x: _natkey(x.filename or '')): + if not f or not f.filename or not _is_image(f.filename): + continue + base = secure_filename(os.path.basename(f.filename)) + if not base: + continue + # Unique-rename if the name is taken (on disk or in db). + stem, ext = os.path.splitext(base) + name = base + n = 1 + while name in existing or os.path.exists(os.path.join(directory, name)): + name = f"{stem}_{n}{ext}" + n += 1 + f.save(os.path.join(directory, name)) + existing.add(name) + order += 1 + db.session.add(TvSlide(surface=surface, filename=name, sortorder=order, seconds=0)) + added.append(name) + + db.session.commit() + return success_response({'added': added}, message=f'{len(added)} slide(s) uploaded') + + +@slides_bp.route('//order', methods=['POST']) +@jwt_required() +@require_role('admin') +def reorder_slides(surface): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + data = request.get_json() or {} + order = data.get('order') or [] + for i, filename in enumerate(order): + row = TvSlide.query.filter_by(surface=surface, filename=filename).first() + if row: + row.sortorder = i + db.session.commit() + return success_response(message='Order saved') + + +@slides_bp.route('//delete', methods=['POST']) +@jwt_required() +@require_role('admin') +def delete_slides(surface): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + data = request.get_json() or {} + files = data.get('files') or [] + directory = _surface_dir(surface) + removed = 0 + for filename in files: + safe = os.path.basename(filename) + row = TvSlide.query.filter_by(surface=surface, filename=safe).first() + fpath = os.path.join(directory, safe) + if os.path.isfile(fpath): + try: + os.remove(fpath) + except OSError: + pass + if row: + db.session.delete(row) + removed += 1 + db.session.commit() + return success_response(message=f'{removed} slide(s) deleted') + + +@slides_bp.route('//', methods=['PATCH']) +@jwt_required() +@require_role('admin') +def update_slide(surface, slideid): + if not _valid_surface(surface): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') + row = db.session.get(TvSlide, slideid) + if not row or row.surface != surface: + return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404) + data = request.get_json() or {} + if 'seconds' in data: + try: + row.seconds = max(0, int(data['seconds'])) + except (TypeError, ValueError): + return error_response(ErrorCodes.VALIDATION_ERROR, 'seconds must be an integer') + db.session.commit() + return success_response(row.to_dict(), message='Slide updated') diff --git a/plugins/slides/manifest.json b/plugins/slides/manifest.json new file mode 100644 index 00000000..b5b8856a --- /dev/null +++ b/plugins/slides/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "slides", + "version": "2.0.0", + "description": "Slide manager for the lobby display and shopfloor screensaver (upload/reorder/delete per surface)", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.2.0,<1.0.0", + "api_prefix": "/api/slides", + "provides": { + "features": ["slideshow", "slidemanager"] + } +} diff --git a/plugins/slides/migrations/env.py b/plugins/slides/migrations/env.py new file mode 100644 index 00000000..7f02c385 --- /dev/null +++ b/plugins/slides/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the slides plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_slides. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'slides' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/slides/migrations/script.py.mako b/plugins/slides/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/slides/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/slides/migrations/versions/0001_slides_anchor.py b/plugins/slides/migrations/versions/0001_slides_anchor.py new file mode 100644 index 00000000..bd2d9496 --- /dev/null +++ b/plugins/slides/migrations/versions/0001_slides_anchor.py @@ -0,0 +1,28 @@ +"""slides plugin anchor (ownership cutover). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_slides. From this +anchor forward, new slides schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'slides0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/slides/models/__init__.py b/plugins/slides/models/__init__.py new file mode 100644 index 00000000..5d433e15 --- /dev/null +++ b/plugins/slides/models/__init__.py @@ -0,0 +1,5 @@ +"""Slides plugin models.""" + +from .tvslide import TvSlide + +__all__ = ['TvSlide'] diff --git a/plugins/slides/models/tvslide.py b/plugins/slides/models/tvslide.py new file mode 100644 index 00000000..a5b5ac17 --- /dev/null +++ b/plugins/slides/models/tvslide.py @@ -0,0 +1,42 @@ +"""Slide model for the TV dashboard / screensaver slideshows.""" + +from datetime import datetime, timezone + +from shopdb.api import db + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class TvSlide(db.Model): + """One slide image in a surface's playlist. + + Image files live on disk at instance/slides//; this row + holds the play order + per-slide duration. Files are the source of truth for + existence - rows whose file is gone are ignored and pruned. + """ + __tablename__ = 'tvslides' + + slideid = db.Column(db.Integer, primary_key=True) + surface = db.Column(db.String(20), nullable=False, index=True) + filename = db.Column(db.String(255), nullable=False) + sortorder = db.Column(db.Integer, default=0, nullable=False) + seconds = db.Column(db.Integer, default=0, nullable=False) # 0 = use default interval + uploadeddate = db.Column(db.DateTime, default=_utcnow) + + # No DB-level unique on (surface, filename): utf8mb4 pushes that index past + # MySQL 5.6's 767-byte limit. Upload unique-renames, so dupes can't occur. + __table_args__ = ( + db.Index('idx_tvslide_surface', 'surface'), + ) + + def to_dict(self): + return { + 'slideid': self.slideid, + 'surface': self.surface, + 'filename': self.filename, + 'sortorder': self.sortorder, + 'seconds': self.seconds, + } diff --git a/plugins/slides/plugin.py b/plugins/slides/plugin.py new file mode 100644 index 00000000..41442f66 --- /dev/null +++ b/plugins/slides/plugin.py @@ -0,0 +1,74 @@ +"""Slides plugin main class. + +Display/service plugin: contributes a blueprint only (no model, no AssetType, +no nav). Serves TV-dashboard slideshow images. Demonstrates the minimal plugin +shape - a feature that is purely an API surface. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta + +from .api import slides_bp +from .models import TvSlide + +logger = logging.getLogger(__name__) + + +class SlidesPlugin(BasePlugin): + """Slides plugin - TV dashboard slideshow images.""" + + 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', 'slides'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'TV dashboard slideshow images' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/slides'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return slides_bp + + def get_models(self) -> List[Type]: + """Slide playlist metadata (image files live on disk).""" + return [TvSlide] + + def get_navigation_items(self) -> List[Dict]: + """Sidebar entry for the slide manager (admin).""" + return [ + { + 'name': 'Slides', + 'icon': 'image', + 'route': '/settings/slides', + 'position': 7, + }, + ] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Slides plugin initialized (v{self.meta.version})") diff --git a/plugins/usb/README.md b/plugins/usb/README.md new file mode 100644 index 00000000..e1b30566 --- /dev/null +++ b/plugins/usb/README.md @@ -0,0 +1,105 @@ +# USB plugin - CMMC USB check-in/out database contract + +The USB plugin tracks removable-media check-in/out for CMMC compliance. Device +and log state live in a **separate, read-write** MySQL database (`cmmc_usb`), +reached with parameterized pymysql via `cmmc_usb_connection()`. Names of people +are resolved from the HR employee directory (see the employees plugin). + +The plugin's own reference tables (`usbdevicetypes`, `usbdevices`, +`usbcheckouts`) live in the main app database; only the live check-in/out data +is in `cmmc_usb`. + +> **The schema is standardized across sites** - the `cmmc_usb` check-in/out +> solution is the same deployment everywhere, so the tables below match as-is +> and no schema adaptation is needed. The **database name may differ per site**, +> though - set `cmmc_usb_db_name` (default `cmmc_usb`) to match the local name. +> The view recipe at the end is only a fallback for a site that somehow differs. +> (Contrast the employee directory, which genuinely varies per site.) + +## Connection + +Credentials resolve **settings-first, then environment**, except the password +which is env-only (never stored in the app database). + +| Field | Setting key (editable in the setup wizard) | Env var (fallback) | +| -------- | ------------------------------------------ | ----------------------- | +| Host | `cmmc_usb_db_host` | `CMMC_USB_DB_HOST` | +| Database | `cmmc_usb_db_name` | `CMMC_USB_DB_NAME` | +| User | `cmmc_usb_db_user` | `CMMC_USB_DB_USER` | +| Password | (not stored) | `CMMC_USB_DB_PASSWORD` | + +Set the password in `.env`; the setup wizard's Features step shows the exact +line to paste. This database is **read-write** - the app account needs +`SELECT, INSERT, UPDATE`. Engine is MySQL/MariaDB (pymysql). + +## Required schema + +The plugin runs raw SQL against these tables (or **views** - see below). + +### `devices` +| Column | Type | Notes | +| ----------------- | ------- | -------------------------------------- | +| `device_id` | VARCHAR | Primary key; the device serial/tag | +| `device_desc` | VARCHAR | Description | +| `device_owner` | VARCHAR | Owner | +| `status` | VARCHAR | Check-in/out state | +| `locker_location` | VARCHAR | Where the device is stored | + +Operations: `SELECT` (list + by id), `INSERT` (register device), `UPDATE` +(edit fields, change status). + +### `checkinoutlog` +| Column | Type | Notes | +| ----------------- | --------- | ---------------------------------- | +| `log_id` | INT (PK) | Auto id | +| `badge_number` | VARCHAR | Person's badge | +| `device_id` | VARCHAR | FK to `devices.device_id` | +| `action` | VARCHAR | check-in / check-out | +| `timestamp` | DATETIME | When it happened | +| `scanned_viruses` | (text/int)| Scan result | +| `locker_location` | VARCHAR | Locker at time of event | +| `sanitized` | (bool/int)| Sanitization flag | + +Operations: `SELECT` (history per device), `INSERT` (log an event). + +### `users` +| Column | Type | Notes | +| -------------- | ------- | ------------------------------------- | +| `badge_number` | VARCHAR | Primary key; the scanned badge | +| `first_name` | VARCHAR | Given name | +| `last_name` | VARCHAR | Surname | + +Operations: `SELECT` by badge, `INSERT` (auto-add a badge on first scan). + +## Employee directory dependency + +To turn a scanned badge into a name, the plugin also reads the HR `employees` +directory (via the employees plugin's connection). A badge shaped `0BZ` +carries a PayNo (the digits); lookups try `employees.SSO` and `employees.PayNo`. +See `plugins/employees/README.md` for that schema and connection. + +## Adapting a different site schema (recommended: views) + +Sites whose USB-tracking database uses different table/column names should +create read-only/updatable **views** named `devices`, `checkinoutlog`, and +`users` that map local columns to the names above. Example: + +```sql +CREATE VIEW devices AS +SELECT + asset_tag AS device_id, + description AS device_desc, + owner AS device_owner, + state AS status, + storage_bay AS locker_location +FROM usb_assets; +``` + +Because the plugin writes to `devices`/`checkinoutlog`/`users`, either make the +views updatable (single-table views usually are) or expose real tables with +these column names. Grant the app account `SELECT, INSERT, UPDATE`. + +Notes: +- `device_id` and `badge_number` are the natural keys the plugin matches on. +- If `cmmc_usb` is unreachable, USB endpoints return an error and the rest of + the app keeps working (the feature degrades, it does not crash the app). diff --git a/plugins/usb/__init__.py b/plugins/usb/__init__.py new file mode 100644 index 00000000..7fd2d87d --- /dev/null +++ b/plugins/usb/__init__.py @@ -0,0 +1,5 @@ +"""USB device checkout plugin.""" + +from .plugin import USBPlugin + +__all__ = ['USBPlugin'] diff --git a/plugins/usb/api/__init__.py b/plugins/usb/api/__init__.py new file mode 100644 index 00000000..0bb291c9 --- /dev/null +++ b/plugins/usb/api/__init__.py @@ -0,0 +1,5 @@ +"""USB plugin API.""" + +from .routes import usb_bp + +__all__ = ['usb_bp'] diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py new file mode 100644 index 00000000..b721b533 --- /dev/null +++ b/plugins/usb/api/routes.py @@ -0,0 +1,723 @@ +"""USB plugin API endpoints. + +Two modes, chosen by the usb_directory_mode setting: + - 'selfhosted' (default): the app-owned tables (usbdevices/usbcheckouts/ + usbdevicetypes) via SQLAlchemy - see selfhosted.py. Each route below + delegates when _usb_selfhosted() is true. + - 'external': a separate MySQL database (cmmc_usb) reached with raw + parameterized pymysql via cmmc_usb_connection(), for a site that already + runs the cmmc_usb solution. That is the raw-SQL code in this file. + +Both modes return the same response shape so the frontend is identical. + +cmmc_usb schema used here: + devices(device_id PK, device_desc, device_owner, status, locker_location) + status is one of 'checked-in' | 'checked-out' | 'retired' + users(badge_number PK, first_name, last_name) + checkinoutlog(log_id PK auto, badge_number, device_id, action, timestamp, + scanned_viruses, locker_location, sanitized) + action is one of 'check-in' | 'check-out'; timestamp is a + 'YYYY-MM-DD HH:MM:SS' string; scanned_viruses/sanitized are '0'|'1'|null. +""" + +import logging +import re +from datetime import datetime + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + cmmc_usb_connection, + employee_connection, + success_response, + error_response, + paginated_response, + ErrorCodes, + get_pagination_params, + require_permission, +) +from shopdb.api import Setting + +from . import selfhosted + +logger = logging.getLogger(__name__) + +usb_bp = Blueprint('usb', __name__) + + +def _usb_selfhosted(): + """True when USB uses the app-owned tables, not the external cmmc_usb DB.""" + row = Setting.query.filter_by(key='usb_directory_mode').first() + return (row.value if row and row.value else 'external').lower() == 'selfhosted' + +# public status strings on the devices row +_STATUS_CHECKED_IN = 'checked-in' +_STATUS_CHECKED_OUT = 'checked-out' +_STATUS_RETIRED = 'retired' + +# map the ?status= query values to the stored devices.status strings +_STATUS_FILTER = { + 'available': _STATUS_CHECKED_IN, + 'checkedout': _STATUS_CHECKED_OUT, + 'retired': _STATUS_RETIRED, +} + +# columns pulled for a devices row +_DEVICE_COLS = 'device_id, device_desc, device_owner, status, locker_location' + +# columns pulled for a checkinoutlog row +_LOG_COLS = ( + 'log_id, badge_number, device_id, action, timestamp, ' + 'scanned_viruses, locker_location, sanitized' +) + +# a badge like 0123456BZ carries a PayNo (the digits) wrapped in 0...BZ +_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE) + + +# ============================================================================= +# Module helpers +# ============================================================================= + +def _now(): + """Current local time as a 'YYYY-MM-DD HH:MM:SS' string for the log.""" + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + +def _flag(value): + """Coerce a truthy/falsy input to a '1'/'0' string, or None when unset.""" + if value is None: + return None + if isinstance(value, bool): + return '1' if value else '0' + text = str(value).strip().lower() + if text in ('1', 'true', 'yes', 'y'): + return '1' + if text in ('0', 'false', 'no', 'n', ''): + return '0' + return None + + +def _lookup_employee_name(badge): + """Look up "First Last" in the HR directory for a badge. Empty on any miss. + + A numeric badge is an SSO. A 0BZ badge carries a PayNo (the middle + digits). Any other shape has no HR mapping so we return "". + """ + badge = (badge or '').strip() + if not badge: + return '' + + try: + conn = employee_connection() + except Exception: + logger.exception('HR directory connection failed for badge %s', badge) + return '' + + try: + with conn.cursor() as ecur: + if badge.isdigit(): + ecur.execute( + 'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s', + (badge,), + ) + else: + match = _PAYNO_BADGE.match(badge) + if not match: + return '' + ecur.execute( + 'SELECT First_Name, Last_Name FROM employees WHERE PayNo = %s', + (match.group(1),), + ) + emp = ecur.fetchone() + except Exception: + logger.exception('HR directory lookup failed for badge %s', badge) + return '' + finally: + conn.close() + + if not emp: + return '' + first = (emp.get('First_Name') or '').strip() + last = (emp.get('Last_Name') or '').strip() + return (first + ' ' + last).strip() + + +def _resolve_badge_name(cur, badge): + """Resolve a badge to "First Last". Try cmmc_usb users, then HR directory. + + cur is an open cmmc_usb cursor. Returns "" when the badge cannot be + resolved anywhere. + """ + badge = (badge or '').strip() if badge is not None else '' + if not badge: + return '' + + cur.execute( + 'SELECT first_name, last_name FROM users WHERE badge_number = %s', + (badge,), + ) + row = cur.fetchone() + if row: + first = (row.get('first_name') or '').strip() + last = (row.get('last_name') or '').strip() + name = (first + ' ' + last).strip() + if name: + return name + + return _lookup_employee_name(badge) + + +def _ensure_user(cur, badge, name): + """Insert a badge into cmmc_usb users if absent. Splits name to first/last.""" + badge = (badge or '').strip() if badge is not None else '' + if not badge: + return + + cur.execute('SELECT badge_number FROM users WHERE badge_number = %s', (badge,)) + if cur.fetchone(): + return + + first, last = '', '' + if name: + parts = name.split() + if parts: + first = parts[0] + last = ' '.join(parts[1:]) + + cur.execute( + 'INSERT INTO users (badge_number, first_name, last_name) VALUES (%s, %s, %s)', + (badge, first, last), + ) + + +def _fetch_device(cur, device_id): + """Return the devices row for device_id, or None.""" + cur.execute( + 'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE device_id = %s', + (device_id,), + ) + return cur.fetchone() + + +def _device_to_dict(cur, row): + """Build the device response dict, resolving owner + current holder names.""" + device = { + 'device_id': row.get('device_id'), + 'device_desc': row.get('device_desc'), + 'device_owner': row.get('device_owner'), + 'owner_name': _resolve_badge_name(cur, row.get('device_owner')), + 'status': row.get('status'), + 'locker_location': row.get('locker_location'), + 'current_holder': None, + 'current_holder_name': None, + 'checkout_time': None, + } + + if row.get('status') == _STATUS_CHECKED_OUT: + cur.execute( + "SELECT badge_number, timestamp FROM checkinoutlog " + "WHERE device_id = %s AND action = 'check-out' " + "ORDER BY timestamp DESC, log_id DESC LIMIT 1", + (row.get('device_id'),), + ) + log = cur.fetchone() + if log: + device['current_holder'] = log.get('badge_number') + device['current_holder_name'] = _resolve_badge_name( + cur, log.get('badge_number') + ) + device['checkout_time'] = log.get('timestamp') + + return device + + +def _log_to_dict(cur, row): + """Build a checkinoutlog response dict with the resolved badge name.""" + result = { + 'log_id': row.get('log_id'), + 'badge_number': row.get('badge_number'), + 'badge_name': _resolve_badge_name(cur, row.get('badge_number')), + 'device_id': row.get('device_id'), + 'action': row.get('action'), + 'timestamp': row.get('timestamp'), + 'scanned_viruses': row.get('scanned_viruses'), + 'locker_location': row.get('locker_location'), + 'sanitized': row.get('sanitized'), + } + # /checkouts joins in the current device status; pass it through when present + if 'device_status' in row: + result['device_status'] = row.get('device_status') + return result + + +# ============================================================================= +# USB Devices +# ============================================================================= + +@usb_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_usb_devices(): + """List USB devices with checkout status. + + Query parameters: + - page, per_page: pagination + - status: available | checkedout | retired + - search: match on device_id or device_desc + """ + if _usb_selfhosted(): + return selfhosted.list_devices(request) + page, per_page = get_pagination_params(request) + + where = [] + params = [] + + status_arg = request.args.get('status', '').strip().lower() + if status_arg in _STATUS_FILTER: + where.append('status = %s') + params.append(_STATUS_FILTER[status_arg]) + + search = request.args.get('search', '').strip() + if search: + where.append('(device_id LIKE %s OR device_desc LIKE %s)') + like = '%' + search + '%' + params.extend([like, like]) + + where_sql = (' WHERE ' + ' AND '.join(where)) if where else '' + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute('SELECT COUNT(*) AS total FROM devices' + where_sql, params) + total = cur.fetchone()['total'] + + cur.execute( + 'SELECT ' + _DEVICE_COLS + ' FROM devices' + where_sql + + ' ORDER BY device_id LIMIT %s OFFSET %s', + params + [per_page, (page - 1) * per_page], + ) + rows = cur.fetchall() + data = [_device_to_dict(cur, row) for row in rows] + finally: + conn.close() + + return paginated_response(data, page, per_page, total) + + +@usb_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_usb_device(device_id): + """Get a single device plus its last 20 check-in/out log rows.""" + if _usb_selfhosted(): + return selfhosted.get_device(device_id) + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + if not row: + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + + result = _device_to_dict(cur, row) + + cur.execute( + 'SELECT ' + _LOG_COLS + ' FROM checkinoutlog WHERE device_id = %s ' + 'ORDER BY timestamp DESC, log_id DESC LIMIT 20', + (device_id,), + ) + logs = cur.fetchall() + result['checkinoutlog'] = [_log_to_dict(cur, log) for log in logs] + finally: + conn.close() + + return success_response(result) + + +@usb_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('usb.create') +def create_usb_device(): + """Add a device (starts checked-in).""" + if _usb_selfhosted(): + return selfhosted.create_device(request.get_json() or {}) + data = request.get_json() or {} + + device_id = (data.get('device_id') or '').strip() + if not device_id: + return error_response(ErrorCodes.VALIDATION_ERROR, 'device_id is required') + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute( + 'SELECT device_id FROM devices WHERE device_id = %s', (device_id,) + ) + if cur.fetchone(): + return error_response( + ErrorCodes.CONFLICT, 'device_id already exists', http_code=409 + ) + + cur.execute( + 'INSERT INTO devices ' + '(device_id, device_desc, device_owner, status, locker_location) ' + 'VALUES (%s, %s, %s, %s, %s)', + ( + device_id, + data.get('device_desc'), + data.get('device_owner'), + _STATUS_CHECKED_IN, + data.get('locker_location'), + ), + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device created', http_code=201) + + +@usb_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('usb.edit') +def update_usb_device(device_id): + """Edit device_desc / device_owner / locker_location / status.""" + if _usb_selfhosted(): + return selfhosted.update_device(device_id, request.get_json() or {}) + data = request.get_json() or {} + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + if not _fetch_device(cur, device_id): + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + + fields = [] + params = [] + for col in ('device_desc', 'device_owner', 'locker_location', 'status'): + if col in data: + fields.append(col + ' = %s') + params.append(data[col]) + + if fields: + params.append(device_id) + cur.execute( + 'UPDATE devices SET ' + ', '.join(fields) + ' WHERE device_id = %s', + params, + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device updated') + + +@usb_bp.route('//retire', methods=['POST']) +@jwt_required() +@require_permission('usb.edit') +def retire_usb_device(device_id): + """Retire a device (status -> retired).""" + if _usb_selfhosted(): + return selfhosted.retire_device(device_id) + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + if not _fetch_device(cur, device_id): + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + cur.execute( + 'UPDATE devices SET status = %s WHERE device_id = %s', + (_STATUS_RETIRED, device_id), + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device retired') + + +# ============================================================================= +# Check-in / check-out operations +# ============================================================================= + +@usb_bp.route('//checkout', methods=['POST']) +@jwt_required() +@require_permission('usb.create') +def checkout_device(device_id): + """Check a device out to a badge.""" + if _usb_selfhosted(): + return selfhosted.checkout_device(device_id, request.get_json() or {}) + data = request.get_json() or {} + + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required') + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + device = _fetch_device(cur, device_id) + if not device: + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + if device.get('status') == _STATUS_CHECKED_OUT: + return error_response( + ErrorCodes.CONFLICT, + 'Device is already checked out', + http_code=409, + ) + + name = _resolve_badge_name(cur, badge) + _ensure_user(cur, badge, name) + + locker = data.get('locker_location') + log_locker = locker if locker is not None else device.get('locker_location') + + cur.execute( + 'INSERT INTO checkinoutlog ' + '(badge_number, device_id, action, timestamp, locker_location) ' + "VALUES (%s, %s, 'check-out', %s, %s)", + (badge, device_id, _now(), log_locker), + ) + + if locker is not None: + cur.execute( + 'UPDATE devices SET status = %s, locker_location = %s ' + 'WHERE device_id = %s', + (_STATUS_CHECKED_OUT, locker, device_id), + ) + else: + cur.execute( + 'UPDATE devices SET status = %s WHERE device_id = %s', + (_STATUS_CHECKED_OUT, device_id), + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device checked out', http_code=201) + + +@usb_bp.route('//checkin', methods=['POST']) +@jwt_required() +@require_permission('usb.create') +def checkin_device(device_id): + """Check a device back in from a badge.""" + if _usb_selfhosted(): + return selfhosted.checkin_device(device_id, request.get_json() or {}) + data = request.get_json() or {} + + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required') + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + device = _fetch_device(cur, device_id) + if not device: + return error_response( + ErrorCodes.NOT_FOUND, + f'USB device {device_id} not found', + http_code=404, + ) + if device.get('status') != _STATUS_CHECKED_OUT: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Device is not currently checked out', + http_code=400, + ) + + name = _resolve_badge_name(cur, badge) + _ensure_user(cur, badge, name) + + locker = data.get('locker_location') + log_locker = locker if locker is not None else device.get('locker_location') + sanitized = _flag(data.get('sanitized')) + scanned_viruses = _flag(data.get('scanned_viruses')) + + cur.execute( + 'INSERT INTO checkinoutlog ' + '(badge_number, device_id, action, timestamp, scanned_viruses, ' + 'locker_location, sanitized) ' + "VALUES (%s, %s, 'check-in', %s, %s, %s, %s)", + (badge, device_id, _now(), scanned_viruses, log_locker, sanitized), + ) + + if locker is not None: + cur.execute( + 'UPDATE devices SET status = %s, locker_location = %s ' + 'WHERE device_id = %s', + (_STATUS_CHECKED_IN, locker, device_id), + ) + else: + cur.execute( + 'UPDATE devices SET status = %s WHERE device_id = %s', + (_STATUS_CHECKED_IN, device_id), + ) + conn.commit() + + with conn.cursor() as cur: + row = _fetch_device(cur, device_id) + result = _device_to_dict(cur, row) + finally: + conn.close() + + return success_response(result, message='Device checked in') + + +# ============================================================================= +# Check-in/out log queries +# ============================================================================= + +@usb_bp.route('//history', methods=['GET']) +@jwt_required(optional=True) +def get_device_history(device_id): + """Paginated check-in/out log for one device.""" + if _usb_selfhosted(): + return selfhosted.device_history(device_id, request) + page, per_page = get_pagination_params(request) + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute( + 'SELECT COUNT(*) AS total FROM checkinoutlog WHERE device_id = %s', + (device_id,), + ) + total = cur.fetchone()['total'] + + cur.execute( + 'SELECT ' + _LOG_COLS + ' FROM checkinoutlog WHERE device_id = %s ' + 'ORDER BY timestamp DESC, log_id DESC LIMIT %s OFFSET %s', + (device_id, per_page, (page - 1) * per_page), + ) + rows = cur.fetchall() + data = [_log_to_dict(cur, row) for row in rows] + finally: + conn.close() + + return paginated_response(data, page, per_page, total) + + +@usb_bp.route('/checkouts', methods=['GET']) +@jwt_required(optional=True) +def list_all_checkouts(): + """List check-out log rows. + + Query parameters: + - active: true -> only rows whose device is currently checked out + - badge: filter by badge_number + """ + if _usb_selfhosted(): + return selfhosted.list_checkouts(request, active_only=False) + page, per_page = get_pagination_params(request) + + where = ["l.action = 'check-out'"] + params = [] + + if request.args.get('active', '').lower() == 'true': + where.append("d.status = 'checked-out'") + + badge = request.args.get('badge', '').strip() + if badge: + where.append('l.badge_number = %s') + params.append(badge) + + where_sql = ' WHERE ' + ' AND '.join(where) + base = ( + 'FROM checkinoutlog l JOIN devices d ON l.device_id = d.device_id' + + where_sql + ) + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute('SELECT COUNT(*) AS total ' + base, params) + total = cur.fetchone()['total'] + + cur.execute( + 'SELECT l.log_id, l.badge_number, l.device_id, l.action, ' + 'l.timestamp, l.scanned_viruses, l.locker_location, l.sanitized, ' + 'd.status AS device_status ' + base + + ' ORDER BY l.timestamp DESC, l.log_id DESC LIMIT %s OFFSET %s', + params + [per_page, (page - 1) * per_page], + ) + rows = cur.fetchall() + data = [_log_to_dict(cur, row) for row in rows] + finally: + conn.close() + + return paginated_response(data, page, per_page, total) + + +@usb_bp.route('/checkouts/active', methods=['GET']) +@jwt_required(optional=True) +def list_active_checkouts(): + """List the latest check-out log row for every currently checked-out device. + + Query parameters: + - badge: filter by badge_number + """ + if _usb_selfhosted(): + return selfhosted.list_checkouts(request, active_only=True) + where_badge = '' + params = [] + badge = request.args.get('badge', '').strip() + if badge: + where_badge = 'AND l.badge_number = %s ' + params.append(badge) + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + cur.execute( + 'SELECT l.log_id, l.badge_number, l.device_id, l.action, ' + 'l.timestamp, l.scanned_viruses, l.locker_location, l.sanitized, ' + 'd.status AS device_status ' + 'FROM devices d ' + 'JOIN checkinoutlog l ON l.device_id = d.device_id ' + "AND l.action = 'check-out' " + "WHERE d.status = 'checked-out' " + + where_badge + + 'AND l.log_id = (' + ' SELECT MAX(l2.log_id) FROM checkinoutlog l2 ' + " WHERE l2.device_id = d.device_id AND l2.action = 'check-out'" + ') ' + 'ORDER BY l.timestamp DESC, l.log_id DESC', + params, + ) + rows = cur.fetchall() + data = [_log_to_dict(cur, row) for row in rows] + finally: + conn.close() + + return success_response(data) diff --git a/plugins/usb/api/selfhosted.py b/plugins/usb/api/selfhosted.py new file mode 100644 index 00000000..4254da32 --- /dev/null +++ b/plugins/usb/api/selfhosted.py @@ -0,0 +1,276 @@ +"""Self-hosted USB check-in/out, backed by the app-owned tables. + +Used when usb_directory_mode=selfhosted: the USB routes call these functions +instead of the external cmmc_usb database. Data lives in the app tables +(usbdevices, usbcheckouts, usbdevicetypes); responses match the same shape the +external path returns so the frontend is identical in both modes. + +Field mapping (response contract <- app model): + device_id <- USBDevice.serialnumber + device_desc <- USBDevice.label + status <- derived: retired (not isactive) / checked-out / checked-in + locker_location <- USBDevice.storagelocation + current_holder <- USBDevice.currentuserid (SSO) + checkout_time <- USBDevice.currentcheckoutdate +History rows are synthesized from usbcheckouts (each row = a check-out event and, +if returned, a check-in event). +""" + +from datetime import datetime, timezone + +from shopdb.api import ( + db, success_response, error_response, ErrorCodes, + get_pagination_params, paginated_response, + apply_import_timestamps, import_mode_active, parse_import_datetime, +) + +from ..models import USBDevice, USBCheckout + +STATUS_CHECKED_IN = 'checked-in' +STATUS_CHECKED_OUT = 'checked-out' +STATUS_RETIRED = 'retired' +_FILTER = {'available': STATUS_CHECKED_IN, 'checkedout': STATUS_CHECKED_OUT, 'retired': STATUS_RETIRED} + + +def _resolve_name(sso): + """Best-effort name from the self-hosted employee directory.""" + try: + from plugins.employees.models import DirectoryEmployee + if sso and str(sso).isdigit(): + emp = db.session.get(DirectoryEmployee, int(sso)) + if emp: + return f'{emp.firstname} {emp.lastname}'.strip() + except Exception: + pass + return None + + +def _status(device): + if not device.isactive: + return STATUS_RETIRED + return STATUS_CHECKED_OUT if device.ischeckedout else STATUS_CHECKED_IN + + +def _device_dict(device): + return { + 'device_id': device.serialnumber, + 'device_desc': device.label, + 'device_owner': None, + 'owner_name': None, + 'status': _status(device), + 'locker_location': device.storagelocation, + 'current_holder': device.currentuserid, + 'current_holder_name': device.currentusername, + 'checkout_time': device.currentcheckoutdate.isoformat() + 'Z' if device.currentcheckoutdate else None, + } + + +def _checkout_events(checkout): + """One usbcheckouts row -> its check-out event (+ check-in event if returned).""" + name = checkout.checkoutname or _resolve_name(checkout.sso) + events = [{ + 'log_id': checkout.checkoutid * 2, + 'badge_number': checkout.sso, + 'badge_name': name, + 'device_id': checkout.device.serialnumber if checkout.device else None, + 'action': 'check-out', + 'timestamp': checkout.checkouttime.isoformat() + 'Z' if checkout.checkouttime else None, + 'scanned_viruses': None, + 'locker_location': None, + 'sanitized': None, + }] + if checkout.checkintime: + events.append({ + 'log_id': checkout.checkoutid * 2 + 1, + 'badge_number': checkout.sso, + 'badge_name': name, + 'device_id': checkout.device.serialnumber if checkout.device else None, + 'action': 'check-in', + 'timestamp': checkout.checkintime.isoformat() + 'Z', + 'scanned_viruses': None, + 'locker_location': None, + 'sanitized': checkout.waswiped, + }) + return events + + +def _find(device_id): + return USBDevice.query.filter_by(serialnumber=device_id).first() + + +# --------------------------------------------------------------------------- + +def list_devices(request): + page, per_page = get_pagination_params(request) + query = USBDevice.query + status_arg = request.args.get('status', '').strip().lower() + if status_arg in _FILTER: + want = _FILTER[status_arg] + if want == STATUS_RETIRED: + query = query.filter_by(isactive=False) + elif want == STATUS_CHECKED_OUT: + query = query.filter_by(isactive=True, ischeckedout=True) + else: + query = query.filter_by(isactive=True, ischeckedout=False) + search = request.args.get('search', '').strip() + if search: + like = f'%{search}%' + query = query.filter(db.or_(USBDevice.serialnumber.ilike(like), + USBDevice.label.ilike(like))) + total = query.count() + rows = (query.order_by(USBDevice.serialnumber) + .limit(per_page).offset((page - 1) * per_page).all()) + return paginated_response([_device_dict(d) for d in rows], page, per_page, total) + + +def get_device(device_id): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + result = _device_dict(device) + checkouts = (USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid) + .order_by(USBCheckout.checkouttime.desc()).limit(20).all()) + logs = [] + for checkout in checkouts: + logs.extend(_checkout_events(checkout)) + logs.sort(key=lambda item: item['timestamp'] or '', reverse=True) + result['checkinoutlog'] = logs[:20] + return success_response(result) + + +def create_device(data): + device_id = (data.get('device_id') or '').strip() + if not device_id: + return error_response(ErrorCodes.VALIDATION_ERROR, 'device_id is required') + if _find(device_id): + return error_response(ErrorCodes.CONFLICT, 'device_id already exists', http_code=409) + device = USBDevice(serialnumber=device_id, label=data.get('device_desc'), + storagelocation=data.get('locker_location'), + ischeckedout=False, isactive=True) + db.session.add(device) + apply_import_timestamps(device, data) + db.session.commit() + return success_response(_device_dict(device), message='Device created', http_code=201) + + +def update_device(device_id, data): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + if 'device_desc' in data: + device.label = data['device_desc'] + if 'locker_location' in data: + device.storagelocation = data['locker_location'] + if 'status' in data: + if data['status'] == STATUS_RETIRED: + device.isactive = False + elif data['status'] in (STATUS_CHECKED_IN, STATUS_CHECKED_OUT): + device.isactive = True + device.ischeckedout = data['status'] == STATUS_CHECKED_OUT + db.session.commit() + return success_response(_device_dict(device), message='Device updated') + + +def retire_device(device_id): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + device.isactive = False + db.session.commit() + return success_response(_device_dict(device), message='Device retired') + + +def checkout_device(device_id, data): + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required') + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + if device.ischeckedout: + return error_response(ErrorCodes.CONFLICT, 'Device is already checked out', http_code=409) + name = _resolve_name(badge) + now = datetime.now(timezone.utc).replace(tzinfo=None) + # Backdated import: an admin import request may pass the historical + # checkouttime so migrated usbcheckouts rows keep their real event time. + eventtime = now + if import_mode_active(): + override = parse_import_datetime(data.get('checkouttime')) + if override is not None: + eventtime = override + db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge, + checkoutname=name, checkouttime=eventtime, + checkoutreason=data.get('reason'))) + device.ischeckedout = True + device.currentuserid = badge + device.currentusername = name + device.currentcheckoutdate = eventtime + if data.get('locker_location'): + device.storagelocation = data['locker_location'] + db.session.commit() + return success_response(_device_dict(device), message='Device checked out', http_code=201) + + +def checkin_device(device_id, data): + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required') + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + if not device.ischeckedout: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Device is not checked out') + open_checkout = (USBCheckout.query + .filter_by(usbdeviceid=device.usbdeviceid, checkintime=None) + .order_by(USBCheckout.checkouttime.desc()).first()) + if open_checkout: + checkintime = datetime.now(timezone.utc).replace(tzinfo=None) + # Backdated import: accept the historical checkintime from an admin + # import request so returned checkouts keep their real return time. + if import_mode_active(): + override = parse_import_datetime(data.get('checkintime')) + if override is not None: + checkintime = override + open_checkout.checkintime = checkintime + open_checkout.waswiped = bool(data.get('sanitized')) + open_checkout.checkinnotes = data.get('notes') + device.ischeckedout = False + device.currentuserid = None + device.currentusername = None + device.currentcheckoutdate = None + if data.get('locker_location'): + device.storagelocation = data['locker_location'] + db.session.commit() + return success_response(_device_dict(device), message='Device checked in') + + +def device_history(device_id, request): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, f'USB device {device_id} not found', http_code=404) + page, per_page = get_pagination_params(request) + checkouts = (USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid) + .order_by(USBCheckout.checkouttime.desc()).all()) + logs = [] + for checkout in checkouts: + logs.extend(_checkout_events(checkout)) + logs.sort(key=lambda item: item['timestamp'] or '', reverse=True) + total = len(logs) + start = (page - 1) * per_page + return paginated_response(logs[start:start + per_page], page, per_page, total) + + +def list_checkouts(request, active_only): + query = USBCheckout.query + if active_only: + query = query.filter_by(checkintime=None) + badge = request.args.get('badge', '').strip() + if badge: + query = query.filter_by(sso=badge) + rows = query.order_by(USBCheckout.checkouttime.desc()).all() + data = [] + for checkout in rows: + event = _checkout_events(checkout)[0] + event['device_status'] = _status(checkout.device) if checkout.device else None + data.append(event) + return success_response(data) diff --git a/plugins/usb/manifest.json b/plugins/usb/manifest.json new file mode 100644 index 00000000..5b595c3e --- /dev/null +++ b/plugins/usb/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "usb", + "display_name": "USB", + "version": "1.0.0", + "description": "USB device checkout management", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/usb", + "default_enabled": false +} \ No newline at end of file diff --git a/plugins/usb/migrations/env.py b/plugins/usb/migrations/env.py new file mode 100644 index 00000000..dfafd6a1 --- /dev/null +++ b/plugins/usb/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the usb plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_usb. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'usb' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/usb/migrations/script.py.mako b/plugins/usb/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/usb/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/usb/migrations/versions/0001_usb_anchor.py b/plugins/usb/migrations/versions/0001_usb_anchor.py new file mode 100644 index 00000000..64a18c0c --- /dev/null +++ b/plugins/usb/migrations/versions/0001_usb_anchor.py @@ -0,0 +1,28 @@ +"""usb plugin anchor (ownership cutover). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_usb. From this +anchor forward, new usb schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'usb0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/usb/migrations/versions/0002_drop_checkout_machineid.py b/plugins/usb/migrations/versions/0002_drop_checkout_machineid.py new file mode 100644 index 00000000..f1861c6d --- /dev/null +++ b/plugins/usb/migrations/versions/0002_drop_checkout_machineid.py @@ -0,0 +1,42 @@ +"""Drop the dead usbcheckouts.machineid column (ADR-001: Machine retired). + +machineid was a NOT NULL soft-reference to the retired machines table; every +new checkout stored the sentinel 0. It has no FK, no meaning under the asset +model, and no reader. Drop it. + +Idempotent (skips a missing table/column). Downgrade restores it NOT NULL with +server_default 0 so any existing rows stay valid. + +Revision ID: usb0002dropmachineid +Revises: usb0001anchor +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'usb0002dropmachineid' +down_revision = 'usb0001anchor' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'usbcheckouts' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('usbcheckouts')} + if 'machineid' in cols: + op.drop_column('usbcheckouts', 'machineid') + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'usbcheckouts' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('usbcheckouts')} + if 'machineid' not in cols: + op.add_column('usbcheckouts', + sa.Column('machineid', sa.Integer(), nullable=False, + server_default='0')) diff --git a/plugins/usb/models/__init__.py b/plugins/usb/models/__init__.py new file mode 100644 index 00000000..9111ebec --- /dev/null +++ b/plugins/usb/models/__init__.py @@ -0,0 +1,5 @@ +"""USB plugin models.""" + +from .usb_device import USBDevice, USBDeviceType, USBCheckout + +__all__ = ['USBDevice', 'USBDeviceType', 'USBCheckout'] diff --git a/plugins/usb/models/usb_device.py b/plugins/usb/models/usb_device.py new file mode 100644 index 00000000..c549cbba --- /dev/null +++ b/plugins/usb/models/usb_device.py @@ -0,0 +1,167 @@ +"""USB device plugin models.""" + +from datetime import datetime, timezone +from shopdb.api import db, BaseModel, AuditMixin + + +def _utcnow(): + # naive UTC for DB columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class USBDeviceType(BaseModel): + """ + USB device type classification. + + Examples: Flash Drive, External HDD, External SSD, Card Reader + """ + __tablename__ = 'usbdevicetypes' + + usbdevicetypeid = db.Column(db.Integer, primary_key=True) + typename = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), default='usb', comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class USBDevice(BaseModel, AuditMixin): + """ + USB device model. + + Tracks USB storage devices that can be checked out by users. + """ + __tablename__ = 'usbdevices' + + usbdeviceid = db.Column(db.Integer, primary_key=True) + + # Identification + serialnumber = db.Column(db.String(100), unique=True, nullable=False) + label = db.Column(db.String(100), nullable=True, comment='Human-readable label') + assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag') + + # Classification + usbdevicetypeid = db.Column( + db.Integer, + db.ForeignKey('usbdevicetypes.usbdevicetypeid'), + nullable=True + ) + + # Specifications + capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB') + vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)') + productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)') + manufacturer = db.Column(db.String(100), nullable=True) + productname = db.Column(db.String(100), nullable=True) + + # Current status + ischeckedout = db.Column(db.Boolean, default=False) + currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user') + currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user') + currentcheckoutdate = db.Column(db.DateTime, nullable=True) + + # Location + storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out') + + # Security + pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices') + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Relationships + devicetype = db.relationship('USBDeviceType', backref='devices') + + # Indexes + __table_args__ = ( + db.Index('idx_usb_checkedout', 'ischeckedout'), + db.Index('idx_usb_type', 'usbdevicetypeid'), + db.Index('idx_usb_currentuser', 'currentuserid'), + ) + + def __repr__(self): + return f"" + + @property + def display_name(self): + """Get display name (label if set, otherwise serial number).""" + return self.label or self.serialnumber + + def to_dict(self): + """Convert to dictionary with related data.""" + result = super().to_dict() + + # Add type info + if self.devicetype: + result['typename'] = self.devicetype.typename + result['typeicon'] = self.devicetype.icon + + # Add computed property + result['displayname'] = self.display_name + + return result + + +class USBCheckout(BaseModel): + """ + USB device checkout history. + + Tracks when devices are checked out and returned. + Maps to existing usbcheckouts table from classic ShopDB. + """ + __tablename__ = 'usbcheckouts' + + checkoutid = db.Column(db.Integer, primary_key=True) + + # Device reference (new column linking to usbdevices table) + usbdeviceid = db.Column( + db.Integer, + db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'), + nullable=True + ) + + # 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=_utcnow) + checkintime = db.Column(db.DateTime, nullable=True) + + # Metadata + checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout') + checkinnotes = db.Column(db.Text, nullable=True) + waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return') + + # Relationships + device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic')) + + def __repr__(self): + return f"" + + @property + def is_active(self): + """Check if this checkout is currently active (not returned).""" + return self.checkintime is None + + @property + def duration_days(self): + """Get duration of checkout in days.""" + end = self.checkintime or datetime.now(timezone.utc).replace(tzinfo=None) + delta = end - self.checkouttime + return delta.days + + def to_dict(self): + """Convert to dictionary with computed fields.""" + result = super().to_dict() + + result['isactivecheckout'] = self.is_active + result['durationdays'] = self.duration_days + + # Add device info if loaded + if self.device: + result['devicelabel'] = self.device.label + result['deviceserialnumber'] = self.device.serialnumber + + return result diff --git a/plugins/usb/plugin.py b/plugins/usb/plugin.py new file mode 100644 index 00000000..85054cae --- /dev/null +++ b/plugins/usb/plugin.py @@ -0,0 +1,120 @@ +"""USB plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta + +from .models import USBDevice, USBDeviceType, USBCheckout +from .api import usb_bp + +logger = logging.getLogger(__name__) + + +class USBPlugin(BasePlugin): + """ + USB plugin - manages USB device tracking and checkouts. + + Standalone plugin for tracking USB flash drives, external drives, + and other portable storage devices with checkout/checkin functionality. + """ + + 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', 'usb'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get('description', 'USB device checkout 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/usb'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return usb_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [USBDeviceType, USBDevice, USBCheckout] + + def get_provisioning_note(self) -> Optional[Dict]: + return { + 'mode_setting': 'usb_directory_mode', + 'tables': ['usbdevicetypes', 'usbdevices', 'usbcheckouts'], + 'note': ('Enabling this creates USB tracking tables in the shopdb ' + 'database (usbdevicetypes, usbdevices, usbcheckouts) for CMMC ' + 'removable-media check-in/out. Devices, check-in/out events, ' + 'and per-event log ids live here. Planned build-out: a USB-ID ' + 'standard, overdue-device email reminders, and DLP/Fabric ' + 'approval integration (see the captured design notes). A site ' + 'with an existing cmmc_usb database can use external mode ' + 'instead.'), + 'docs': 'plugins/usb/README.md', + } + + def get_config_schema(self) -> List[Dict]: + """CMMC USB check-in/out database connection. Host/name/user are settings + the wizard can edit; the password stays in .env (emitted, not stored).""" + return [ + {'key': 'cmmc_usb_db_host', 'label': 'USB DB host', 'type': 'text', + 'secret': False, 'default': 'localhost', + 'help': 'This DB must expose devices / checkinoutlog / users tables ' + '(or views). See plugins/usb/README.md.'}, + {'key': 'cmmc_usb_db_name', 'label': 'USB DB name', 'type': 'text', + 'secret': False, 'default': 'cmmc_usb'}, + {'key': 'cmmc_usb_db_user', 'label': 'USB DB user', 'type': 'text', + 'secret': False}, + {'key': 'cmmc_usb_db_password', 'label': 'USB DB password', 'type': 'password', + 'secret': True, 'envvar': 'CMMC_USB_DB_PASSWORD', + 'help': 'Stored in .env, not the database. The wizard shows the line to paste.'}, + ] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"USB plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + logger.info("USB plugin installed") + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("USB plugin uninstalled") + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'USB Devices', + 'icon': 'usb', + 'route': '/usb', + 'position': 45, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('usb.view', 'View USB devices', 'usb'), + ('usb.create', 'Create USB devices', 'usb'), + ('usb.edit', 'Edit USB devices', 'usb'), + ('usb.delete', 'Delete USB devices', 'usb'), + ] diff --git a/plugins/warranty/__init__.py b/plugins/warranty/__init__.py new file mode 100644 index 00000000..e4890064 --- /dev/null +++ b/plugins/warranty/__init__.py @@ -0,0 +1,5 @@ +"""Warranty plugin package.""" + +from .plugin import WarrantyPlugin + +__all__ = ['WarrantyPlugin'] diff --git a/plugins/warranty/api/__init__.py b/plugins/warranty/api/__init__.py new file mode 100644 index 00000000..d76a37f4 --- /dev/null +++ b/plugins/warranty/api/__init__.py @@ -0,0 +1,5 @@ +"""Warranty plugin API.""" + +from .routes import warranty_bp + +__all__ = ['warranty_bp'] diff --git a/plugins/warranty/api/routes.py b/plugins/warranty/api/routes.py new file mode 100644 index 00000000..890e3227 --- /dev/null +++ b/plugins/warranty/api/routes.py @@ -0,0 +1,336 @@ +"""Warranty API: manual CRUD now, provider refresh stubbed for later phases. + +Coverage status is derived from enddate at read time (see models.derive_status), +never stored. Warranties link to assets many-to-many via warrantyassets, though +the common case is one warranty per asset. +""" + +from datetime import date, datetime, timezone + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required +from sqlalchemy.orm import joinedload + +from shopdb.api import ( + db, Asset, + success_response, error_response, ErrorCodes, + require_permission, +) + +from ..models import Warranty, WarrantyAsset +from ..services import get_provider, ProviderNotConfigured, WarrantyLookupError + +warranty_bp = Blueprint('warranty', __name__) + + +def _parse_date(value): + """Accept 'YYYY-MM-DD' (or None/empty) -> date or None.""" + if not value: + return None + try: + return datetime.strptime(value[:10], '%Y-%m-%d').date() + except (ValueError, TypeError): + return None + + +def _asset_summary(asset): + return { + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'name': asset.name, + 'assettypename': asset.assettype.assettype if asset.assettype else None, + } + + +def _warranty_payload(warranty, today=None, assetmap=None): + """to_dict plus the linked-asset summaries. Pass assetmap (assetid -> Asset) + to avoid a per-link query when serializing a list; without it, falls back to + a per-link get (fine for a single warranty).""" + data = warranty.to_dict(today) + assets = [] + for link in warranty.links: + asset = assetmap.get(link.assetid) if assetmap is not None \ + else db.session.get(Asset, link.assetid) + if asset: + assets.append(_asset_summary(asset)) + data['assets'] = assets + return data + + +def _apply_links(warranty, assetids): + """Replace a warranty's asset links with the given asset id list.""" + if assetids is None: + return + wanted = {int(a) for a in assetids if str(a).strip()} + existing = {link.assetid: link for link in warranty.links} + for assetid in wanted - set(existing): + if db.session.get(Asset, assetid): + warranty.links.append(WarrantyAsset(assetid=assetid)) + for assetid in set(existing) - wanted: + warranty.links.remove(existing[assetid]) + + +# ============================================================================= +# CRUD +# ============================================================================= + +@warranty_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_warranties(): + """List warranties. Filters: ?status=, ?assetid=, ?active=false.""" + query = Warranty.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter_by(isactive=True) + # Exact-match natural-key lookup for idempotent import (servicetag + vendor). + if exactservicetag := request.args.get('servicetag'): + query = query.filter(Warranty.servicetag == exactservicetag) + if exactvendor := request.args.get('vendor'): + query = query.filter(Warranty.vendor == exactvendor) + assetid = request.args.get('assetid', type=int) + if assetid: + query = (query.join(WarrantyAsset, WarrantyAsset.warrantyid == Warranty.warrantyid) + .filter(WarrantyAsset.assetid == assetid)) + warranties = (query.options(joinedload(Warranty.links)) + .order_by(Warranty.enddate.is_(None), Warranty.enddate).all()) + + # Batch-fetch every linked asset in ONE query (was N+1: a db.session.get per + # link per warranty, ~1.8s for the full list). + assetids = {link.assetid for w in warranties for link in w.links} + assetmap = ({a.assetid: a for a in Asset.query.filter(Asset.assetid.in_(assetids)).all()} + if assetids else {}) + + today = date.today() + items = [_warranty_payload(w, today, assetmap) for w in warranties] + + status_filter = request.args.get('status') + if status_filter: + items = [i for i in items if i['status'] == status_filter] + return success_response(items) + + +@warranty_bp.route('/asset/', methods=['GET']) +@jwt_required(optional=True) +def warranties_for_asset(assetid): + """Warranties covering one asset (for the asset-detail panel).""" + links = WarrantyAsset.query.filter_by(assetid=assetid).all() + today = date.today() + items = [] + for link in links: + w = db.session.get(Warranty, link.warrantyid) + if w and w.isactive: + items.append(_warranty_payload(w, today)) + return success_response(items) + + +@warranty_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_warranty(warrantyid): + warranty = db.session.get(Warranty, warrantyid) + if not warranty: + return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404) + return success_response(_warranty_payload(warranty)) + + +@warranty_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('warranty.create') +def create_warranty(): + data = request.get_json() or {} + vendor = (data.get('vendor') or '').strip() + if not vendor: + return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required') + warranty = Warranty( + vendor=vendor, + servicetag=(data.get('servicetag') or '').strip() or None, + provider=(data.get('provider') or 'manual').strip().lower(), + servicelevel=(data.get('servicelevel') or '').strip() or None, + startdate=_parse_date(data.get('startdate')), + enddate=_parse_date(data.get('enddate')), + notes=(data.get('notes') or '').strip() or None, + ) + _apply_links(warranty, data.get('assetids')) + db.session.add(warranty) + db.session.commit() + return success_response(_warranty_payload(warranty), message='Warranty created', http_code=201) + + +@warranty_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('warranty.edit') +def update_warranty(warrantyid): + warranty = db.session.get(Warranty, warrantyid) + if not warranty: + return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404) + data = request.get_json() or {} + if 'vendor' in data: + warranty.vendor = (data['vendor'] or '').strip() or warranty.vendor + if 'servicetag' in data: + warranty.servicetag = (data['servicetag'] or '').strip() or None + if 'provider' in data: + warranty.provider = (data['provider'] or 'manual').strip().lower() + if 'servicelevel' in data: + warranty.servicelevel = (data['servicelevel'] or '').strip() or None + if 'startdate' in data: + warranty.startdate = _parse_date(data['startdate']) + if 'enddate' in data: + warranty.enddate = _parse_date(data['enddate']) + if 'notes' in data: + warranty.notes = (data['notes'] or '').strip() or None + if 'isactive' in data: + warranty.isactive = bool(data['isactive']) + if 'assetids' in data: + _apply_links(warranty, data['assetids']) + db.session.commit() + return success_response(_warranty_payload(warranty), message='Warranty updated') + + +@warranty_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('warranty.delete') +def delete_warranty(warrantyid): + warranty = db.session.get(Warranty, warrantyid) + if not warranty: + return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404) + db.session.delete(warranty) + db.session.commit() + return success_response(message='Warranty deleted') + + +# ============================================================================= +# Provider refresh (phase 1: manual only; API providers report not-configured) +# ============================================================================= + +@warranty_bp.route('//refresh', methods=['POST']) +@jwt_required() +@require_permission('warranty.edit') +def refresh_warranty(warrantyid): + warranty = db.session.get(Warranty, warrantyid) + if not warranty: + return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404) + provider = get_provider(warranty.provider) + try: + result = provider.lookup(warranty.servicetag, warranty.vendor) + except (ProviderNotConfigured, WarrantyLookupError) as exc: + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400) + if not result: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'This warranty is manual - nothing to refresh.', http_code=400) + if result.get('servicelevel'): + warranty.servicelevel = result['servicelevel'] + if result.get('startdate'): + warranty.startdate = _parse_date(result['startdate']) + if result.get('enddate'): + warranty.enddate = _parse_date(result['enddate']) + warranty.lastcheckeddate = datetime.now(timezone.utc).replace(tzinfo=None) + db.session.commit() + return success_response(_warranty_payload(warranty), message='Warranty refreshed') + + +# ============================================================================= +# Bulk Dell sync - auto-detect Dell hardware by service tag and pull coverage +# ============================================================================= + +@warranty_bp.route('/sync/dell', methods=['POST']) +@jwt_required() +@require_permission('warranty.edit') +def sync_dell(): + """Look up every asset serial (= Dell service tag) that has no warranty yet + and create warranties for the ones Dell recognizes. + + Only assets that lack a dated warranty are looked up, so re-running is cheap + and non-Dell serials are filtered out by Dell (they return no coverage). + """ + provider = get_provider('dell') + # ?all=true re-checks assets that already have a dated warranty too. + recheck_all = request.args.get('all', 'false').lower() == 'true' + + # Assets already covered by a dated warranty - skipped unless recheck_all. + covered = set() + if not recheck_all: + for link in WarrantyAsset.query.all(): + w = db.session.get(Warranty, link.warrantyid) + if w and w.isactive and w.enddate: + covered.add(link.assetid) + + # Candidate assets: active, have a serial, not already covered. + candidates = (Asset.query + .filter(Asset.isactive.is_(True), + Asset.serialnumber.isnot(None), + Asset.serialnumber != '') + .all()) + by_tag = {} + for asset in candidates: + if asset.assetid in covered: + continue + by_tag.setdefault(asset.serialnumber.strip().upper(), []).append(asset.assetid) + + if not by_tag: + return success_response({ + 'candidates': 0, 'matched': 0, 'created': 0, 'updated': 0, + }, message='No PCs need a warranty lookup.') + + try: + results = provider.bulk_lookup(list(by_tag.keys())) + except (ProviderNotConfigured, WarrantyLookupError) as exc: + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400) + + created = updated = matched = 0 + now = datetime.now(timezone.utc).replace(tzinfo=None) + for tag, assetids in by_tag.items(): + found = results.get(tag) + if not found: + continue + matched += 1 + for assetid in assetids: + # Reuse an existing Dell warranty for this asset if there is one. + existing = None + for link in WarrantyAsset.query.filter_by(assetid=assetid).all(): + candidate = db.session.get(Warranty, link.warrantyid) + if candidate and candidate.provider == 'dell': + existing = candidate + break + if existing: + existing.servicelevel = found.get('servicelevel') + existing.startdate = _parse_date(found.get('startdate')) + existing.enddate = _parse_date(found.get('enddate')) + existing.servicetag = tag + existing.lastcheckeddate = now + updated += 1 + else: + warranty = Warranty( + vendor='Dell', provider='dell', servicetag=tag, + servicelevel=found.get('servicelevel'), + startdate=_parse_date(found.get('startdate')), + enddate=_parse_date(found.get('enddate')), + lastcheckeddate=now, + ) + warranty.links.append(WarrantyAsset(assetid=assetid)) + db.session.add(warranty) + created += 1 + + db.session.commit() + return success_response({ + 'candidates': sum(len(v) for v in by_tag.values()), + 'tags': len(by_tag), + 'matched': matched, + 'created': created, + 'updated': updated, + }, message=f'Dell sync: {created} added, {updated} updated, {matched} tags matched.') + + +# ============================================================================= +# Report buckets (for the Reports hub) +# ============================================================================= + +@warranty_bp.route('/report', methods=['GET']) +@jwt_required(optional=True) +def warranty_report(): + """Counts + lists bucketed by derived status.""" + today = date.today() + buckets = {'expired': [], 'expiring': [], 'active': [], 'unknown': []} + for w in Warranty.query.filter_by(isactive=True).all(): + buckets.setdefault(w.status(today), []).append(_warranty_payload(w, today)) + return success_response({ + 'counts': {k: len(v) for k, v in buckets.items()}, + 'buckets': buckets, + }) diff --git a/plugins/warranty/manifest.json b/plugins/warranty/manifest.json new file mode 100644 index 00000000..d744e228 --- /dev/null +++ b/plugins/warranty/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "warranty", + "version": "1.0.0", + "description": "Asset warranty tracking - manual entry now, Dell/Lenovo/HP provider lookups later. Derived coverage status + report buckets.", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.2.0,<1.0.0", + "api_prefix": "/api/warranty", + "provides": { + "features": ["warranty-tracking"] + } +} diff --git a/plugins/warranty/migrations/env.py b/plugins/warranty/migrations/env.py new file mode 100644 index 00000000..94f4114f --- /dev/null +++ b/plugins/warranty/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the warranty plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_warranty. See ADR-008 for the +ownership cutover between the core chain and per-plugin chains. +""" +import os + +os.environ['PLUGIN_NAME'] = 'warranty' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/warranty/migrations/script.py.mako b/plugins/warranty/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/warranty/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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 identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/warranty/migrations/versions/0001_warranty_anchor.py b/plugins/warranty/migrations/versions/0001_warranty_anchor.py new file mode 100644 index 00000000..4bbf84bf --- /dev/null +++ b/plugins/warranty/migrations/versions/0001_warranty_anchor.py @@ -0,0 +1,28 @@ +"""warranty plugin anchor (ownership cutover). + +Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees) +already created every table this plugin owns at the cutover point, so there is +nothing to build here. This revision gives the plugin's own chain a base that +`flask plugin upgrade-all` can stamp into alembic_version_warranty. From this +anchor forward, new warranty schema changes land as 000N revisions in this +directory, never in the core chain. See ADR-008. +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = 'warranty0001anchor' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the core chain owns every table that exists at the cutover. + pass + + +def downgrade(): + # No-op: this anchor never unwinds core-owned tables. + pass diff --git a/plugins/warranty/models/__init__.py b/plugins/warranty/models/__init__.py new file mode 100644 index 00000000..a4adec20 --- /dev/null +++ b/plugins/warranty/models/__init__.py @@ -0,0 +1,5 @@ +"""Warranty plugin models.""" + +from .warranty import Warranty, WarrantyAsset, derive_status, STATUS_COLORS + +__all__ = ['Warranty', 'WarrantyAsset', 'derive_status', 'STATUS_COLORS'] diff --git a/plugins/warranty/models/warranty.py b/plugins/warranty/models/warranty.py new file mode 100644 index 00000000..81996209 --- /dev/null +++ b/plugins/warranty/models/warranty.py @@ -0,0 +1,96 @@ +"""Warranty models. + +A Warranty is provider-agnostic (manual entry, or looked up from Dell/Lenovo/HP +later). It links to one or more assets via warrantyassets. Coverage status is +DERIVED from enddate at read time, never stored, so it is always current. +""" + +from datetime import date, timedelta + +from shopdb.api import db + +# Window before enddate where a warranty counts as "expiring soon". +EXPIRING_WINDOW_DAYS = 180 + +# Derived status -> display color (hex). Reused by the frontend status badge. +STATUS_COLORS = { + 'active': '#4CAF50', + 'expiring': '#FF9800', + 'expired': '#F44336', + 'unknown': '#9E9E9E', +} + + +def derive_status(enddate, today=None): + """Coverage status from an end date. Never stored - always computed.""" + if not enddate: + return 'unknown' + today = today or date.today() + if enddate < today: + return 'expired' + if enddate <= today + timedelta(days=EXPIRING_WINDOW_DAYS): + return 'expiring' + return 'active' + + +class Warranty(db.Model): + __tablename__ = 'warranties' + + warrantyid = db.Column(db.Integer, primary_key=True) + vendor = db.Column(db.String(100), nullable=False) + # Service tag / serial the provider identifies the unit by. + servicetag = db.Column(db.String(100)) + # Where the record came from: manual, dell, lenovo, hp. + provider = db.Column(db.String(20), nullable=False, server_default='manual') + servicelevel = db.Column(db.String(150)) + startdate = db.Column(db.Date) + enddate = db.Column(db.Date) + # When a provider lookup last refreshed this record. + lastcheckeddate = db.Column(db.DateTime) + notes = db.Column(db.Text) + isactive = db.Column(db.Boolean, nullable=False, server_default='1') + + links = db.relationship('WarrantyAsset', back_populates='warranty', + cascade='all, delete-orphan') + + def status(self, today=None): + return derive_status(self.enddate, today) + + def to_dict(self, today=None): + status = self.status(today) + return { + 'warrantyid': self.warrantyid, + 'vendor': self.vendor, + 'servicetag': self.servicetag, + 'provider': self.provider, + 'servicelevel': self.servicelevel, + 'startdate': self.startdate.isoformat() if self.startdate else None, + 'enddate': self.enddate.isoformat() if self.enddate else None, + 'lastcheckeddate': self.lastcheckeddate.isoformat() + 'Z' if self.lastcheckeddate else None, + 'notes': self.notes, + 'isactive': bool(self.isactive), + 'status': status, + 'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']), + } + + +class WarrantyAsset(db.Model): + __tablename__ = 'warrantyassets' + + id = db.Column(db.Integer, primary_key=True) + warrantyid = db.Column( + db.Integer, + db.ForeignKey('warranties.warrantyid', ondelete='CASCADE'), + nullable=False + ) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False + ) + + warranty = db.relationship('Warranty', back_populates='links') + + __table_args__ = ( + db.UniqueConstraint('warrantyid', 'assetid', name='uq_warrantyasset_warranty_asset'), + ) diff --git a/plugins/warranty/plugin.py b/plugins/warranty/plugin.py new file mode 100644 index 00000000..49ad7617 --- /dev/null +++ b/plugins/warranty/plugin.py @@ -0,0 +1,101 @@ +"""Warranty plugin main class. + +Asset-general plugin: owns its warranties + warrantyassets tables, an API +surface, and a sidebar entry. Not tied to any one asset type - a warranty can +cover a PC, printer, network device, or machine. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta + +from .api import warranty_bp +from .models import Warranty, WarrantyAsset + +logger = logging.getLogger(__name__) + + +class WarrantyPlugin(BasePlugin): + """Warranty tracking plugin.""" + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + 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 PluginMeta( + name=self._manifest.get('name', 'warranty'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get('description', 'Asset warranty tracking'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.2.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/warranty'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return warranty_bp + + def get_models(self) -> List[Type]: + return [Warranty, WarrantyAsset] + + def get_navigation_items(self) -> List[Dict]: + return [ + { + 'name': 'Warranties', + 'icon': 'shield', + 'route': '/warranties', + 'position': 8, + }, + ] + + def get_reports(self) -> List[Dict]: + return [ + { + 'id': 'warranty', + 'name': 'Warranty Report', + 'description': 'Assets bucketed by coverage: expired, expiring soon, active', + 'category': 'warranty', + 'route': '/reports/warranty', + }, + ] + + def get_asset_panels(self) -> List[Dict]: + # ADR-010 pilot. Warranty is asset-general so it shows on every detail + # page (['*']). Declares the existing per-asset warranty endpoint the + # WarrantyPanel already reads; the generic core panel renderer consumes + # this instead of each detail view hand-composing the panel. + return [ + { + 'id': 'warranty', + 'title': 'Warranty', + 'assettypes': ['*'], + 'endpoint': '/api/warranty/asset/{assetid}', + 'render': 'table', + 'position': 30, + }, + ] + + def get_permissions(self) -> List: + """Return the RBAC permissions this plugin owns.""" + return [ + ('warranty.view', 'View warranties', 'warranty'), + ('warranty.create', 'Create warranties', 'warranty'), + ('warranty.edit', 'Edit warranties', 'warranty'), + ('warranty.delete', 'Delete warranties', 'warranty'), + ] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f"Warranty plugin initialized (v{self.meta.version})") diff --git a/plugins/warranty/services/__init__.py b/plugins/warranty/services/__init__.py new file mode 100644 index 00000000..efe3743f --- /dev/null +++ b/plugins/warranty/services/__init__.py @@ -0,0 +1,10 @@ +"""Warranty plugin services.""" + +from .providers import ( + get_provider, + provider_names, + ProviderNotConfigured, + WarrantyLookupError, +) + +__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured', 'WarrantyLookupError'] diff --git a/plugins/warranty/services/providers.py b/plugins/warranty/services/providers.py new file mode 100644 index 00000000..d4ae6c37 --- /dev/null +++ b/plugins/warranty/services/providers.py @@ -0,0 +1,269 @@ +"""Warranty provider abstraction. + +A provider looks up coverage for a unit by service tag / serial. Manual entry +needs no provider. Dell is a real integration (Dell TechDirect Warranty API, +OAuth2 client-credentials); Lenovo/HP remain config-shaped stubs until wired. + +Per-vendor config lives in settings (warranty__*), masked where secret, +so nothing is hardcoded and the integration is toggled per site. +""" + +import hashlib +import json +import os +import time + +import requests +from flask import current_app + +from shopdb.api import Setting + +# Two-level Dell token cache. Dell rate-limits the token endpoint, so a fresh +# request per refresh (or per app restart) trips a 401 cooldown. Tokens live ~1h. +# L1 = process memory (fast); L2 = a file in the instance dir so the token +# survives restarts and is shared across workers - mirrors warranty_sync.py. +_dell_token_cache = {} + + +def _token_file(clientid): + # Per-client file, name keyed by a hash so the id never lands on disk. + tag = hashlib.sha256((clientid or '').encode()).hexdigest()[:16] + return os.path.join(current_app.instance_path, f'.dell_token_{tag}.json') + + +def _read_file_token(clientid): + try: + with open(_token_file(clientid)) as handle: + cached = json.load(handle) + if cached.get('expires_at', 0) > time.time() + 60: + return cached['token'] + except (OSError, ValueError, KeyError): + pass + return None + + +def _write_file_token(clientid, token, expires_at): + try: + os.makedirs(current_app.instance_path, exist_ok=True) + with open(_token_file(clientid), 'w') as handle: + json.dump({'token': token, 'expires_at': expires_at}, handle) + except OSError: + pass + +# Dell TechDirect defaults, overridable via settings if Dell changes endpoints. +# The asset-entitlements service lives under sbil/eapi (device.warranty path 404s +# with "Service Not Found" for this account). Verified against the live API. +DELL_TOKEN_URL = 'https://apigtwb2c.us.dell.com/auth/oauth/v2/token' +DELL_API_URL = 'https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements' + +HTTP_TIMEOUT = 15 # seconds + + +class ProviderNotConfigured(Exception): + """Raised when a provider is asked to look up but has no API config.""" + pass + + +class WarrantyLookupError(Exception): + """Raised when a configured provider's lookup fails at runtime.""" + pass + + +def _setting(key, default=None): + row = Setting.query.filter_by(key=key).first() + return row.value if row and row.value not in (None, '') else default + + +def _flag(key): + return str(_setting(key, 'false')).lower() == 'true' + + +class WarrantyProvider: + """Base provider. name matches Warranty.provider values.""" + name = 'base' + + def lookup(self, servicetag, vendor=None): + """Return {servicelevel, startdate, enddate} or None. + + Raises ProviderNotConfigured when creds are missing, WarrantyLookupError + when a configured lookup fails. + """ + raise NotImplementedError + + +class ManualProvider(WarrantyProvider): + """No external lookup - values are entered by hand.""" + name = 'manual' + + def lookup(self, servicetag, vendor=None): + return None + + +class DellProvider(WarrantyProvider): + """Dell TechDirect Warranty API v5 (OAuth2 client-credentials).""" + name = 'dell' + + def _config(self): + return { + 'enabled': _flag('warranty_dell_enabled'), + 'clientid': _setting('warranty_dell_clientid'), + 'clientsecret': _setting('warranty_dell_clientsecret'), + 'tokenurl': _setting('warranty_dell_tokenurl', DELL_TOKEN_URL), + 'apiurl': _setting('warranty_dell_apiurl', DELL_API_URL), + } + + def _get_token(self, config): + clientid = config['clientid'] + # L1: process memory. + cached = _dell_token_cache.get(clientid) + if cached and cached['expires_at'] > time.time() + 60: + return cached['token'] + # L2: file cache (survives restarts / shared across workers). + file_token = _read_file_token(clientid) + if file_token: + return file_token + + # Dell expects the client id/secret as HTTP Basic auth, grant type in the + # body. (Passing them in the body trips the token endpoint's rate limiter.) + try: + response = requests.post( + config['tokenurl'], + auth=(config['clientid'], config['clientsecret']), + data={'grant_type': 'client_credentials'}, + timeout=HTTP_TIMEOUT, + ) + except requests.RequestException as exc: + raise WarrantyLookupError(f'Dell auth failed: {exc}') + if response.status_code in (401, 429): + raise WarrantyLookupError( + 'Dell token endpoint is rate-limiting (cooldown). Wait a few ' + 'minutes and try again - it caches once obtained.' + ) + try: + response.raise_for_status() + token = response.json().get('access_token') + expires_in = int(response.json().get('expires_in', 3600)) + except (requests.RequestException, ValueError) as exc: + raise WarrantyLookupError(f'Dell auth failed: {exc}') + if not token: + raise WarrantyLookupError('Dell auth returned no access_token') + expires_at = time.time() + expires_in - 60 + _dell_token_cache[clientid] = {'token': token, 'expires_at': expires_at} + _write_file_token(clientid, token, expires_at) + return token + + def _fetch(self, config, token, servicetag): + try: + response = requests.get( + config['apiurl'], + params={'servicetags': servicetag}, + headers={'Authorization': f'Bearer {token}', 'Accept': 'application/json'}, + timeout=HTTP_TIMEOUT, + ) + response.raise_for_status() + return response.json() + except (requests.RequestException, ValueError) as exc: + raise WarrantyLookupError(f'Dell warranty lookup failed: {exc}') + + @staticmethod + def _map(payload): + """Reduce the Dell response to {servicelevel, startdate, enddate}. + + Dell returns a list of assets, each with an 'entitlements' list. Coverage + spans the earliest start to the latest end across entitlements. + """ + assets = payload if isinstance(payload, list) else [payload] + entitlements = [] + for asset in assets: + if asset.get('invalid'): + continue + entitlements.extend(asset.get('entitlements') or []) + # Only entitlements with an end date define coverage. + dated = [e for e in entitlements if e.get('endDate')] + if not dated: + return None + starts = [e['startDate'] for e in entitlements if e.get('startDate')] + latest = max(dated, key=lambda e: e['endDate']) + return { + 'servicelevel': latest.get('serviceLevelDescription') or latest.get('serviceLevelCode'), + 'startdate': min(starts)[:10] if starts else None, + 'enddate': max(e['endDate'] for e in dated)[:10], + } + + def lookup(self, servicetag, vendor=None): + config = self._config() + if not (config['enabled'] and config['clientid'] and config['clientsecret']): + raise ProviderNotConfigured( + 'Dell warranty lookup is not configured. Set warranty_dell_enabled, ' + 'clientid and clientsecret in Settings > Integrations.' + ) + if not servicetag: + raise WarrantyLookupError('Dell lookup needs a service tag on the warranty.') + token = self._get_token(config) + return self._map(self._fetch(config, token, servicetag)) + + def bulk_lookup(self, servicetags): + """Look up many tags at once. Dell takes up to 100 tags per call. + + Returns {SERVICETAG_UPPER: {servicelevel, startdate, enddate}} for tags + Dell recognizes; unknown/non-Dell tags are simply absent. + """ + config = self._config() + if not (config['enabled'] and config['clientid'] and config['clientsecret']): + raise ProviderNotConfigured( + 'Dell warranty lookup is not configured. Set warranty_dell_enabled, ' + 'clientid and clientsecret in Settings > Integrations.' + ) + tags = [t.strip() for t in servicetags if t and t.strip()] + if not tags: + return {} + token = self._get_token(config) + results = {} + for start in range(0, len(tags), 100): + chunk = tags[start:start + 100] + payload = self._fetch(config, token, ','.join(chunk)) + assets = payload if isinstance(payload, list) else [payload] + for asset in assets: + tag = (asset.get('serviceTag') or '').upper() + mapped = self._map([asset]) + if tag and mapped: + results[tag] = mapped + return results + + +class ApiProvider(WarrantyProvider): + """Generic config-shaped stub for vendors not yet wired (Lenovo, HP).""" + + def lookup(self, servicetag, vendor=None): + enabled = _flag(f'warranty_{self.name}_enabled') + url = _setting(f'warranty_{self.name}_apiurl') + token = _setting(f'warranty_{self.name}_apitoken') + if not (enabled and url and token): + raise ProviderNotConfigured( + f'{self.name} warranty lookup is not configured.' + ) + raise ProviderNotConfigured( + f'{self.name} API lookup not implemented yet (config present).' + ) + + +class LenovoProvider(ApiProvider): + name = 'lenovo' + + +class HpProvider(ApiProvider): + name = 'hp' + + +_PROVIDERS = {p.name: p for p in ( + ManualProvider(), DellProvider(), LenovoProvider(), HpProvider() +)} + + +def get_provider(name): + """Return a provider instance, defaulting to manual for unknown names.""" + return _PROVIDERS.get((name or 'manual').lower(), _PROVIDERS['manual']) + + +def provider_names(): + return list(_PROVIDERS.keys()) diff --git a/requirements.in b/requirements.in new file mode 100644 index 00000000..87eb7d14 --- /dev/null +++ b/requirements.in @@ -0,0 +1,32 @@ +# Flask and extensions +flask>=3.0 +flask-sqlalchemy>=3.1 +flask-migrate>=4.0 +flask-jwt-extended>=4.6 +flask-cors>=4.0 +flask-caching>=2.0 +flask-marshmallow>=1.2 +marshmallow-sqlalchemy>=0.29 + +# Database +mysql-connector-python>=8.0 +pymysql>=1.1 + +# CLI and utilities +click>=8.1 +python-dotenv>=1.0 +tabulate>=0.9 + +# HTTP/API clients +requests>=2.31 + +# Security +werkzeug>=3.0 + +# Validation +email-validator>=2.0 + +# Testing +pytest>=7.0 +pytest-flask>=1.2 +pytest-cov>=4.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..d48e1663 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,121 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in -o requirements.txt +alembic==1.18.4 + # via flask-migrate +blinker==1.9.0 + # via flask +cachelib==0.13.0 + # via flask-caching +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +click==8.3.3 + # via + # -r requirements.in + # flask +coverage==7.13.5 + # via pytest-cov +dnspython==2.8.0 + # via email-validator +email-validator==2.3.0 + # via -r requirements.in +flask==3.1.3 + # via + # -r requirements.in + # flask-caching + # flask-cors + # flask-jwt-extended + # flask-marshmallow + # flask-migrate + # flask-sqlalchemy + # pytest-flask +flask-caching==2.4.0 + # via -r requirements.in +flask-cors==6.0.2 + # via -r requirements.in +flask-jwt-extended==4.7.3 + # via -r requirements.in +flask-marshmallow==1.5.0 + # via -r requirements.in +flask-migrate==4.1.0 + # via -r requirements.in +flask-sqlalchemy==3.1.1 + # via + # -r requirements.in + # flask-migrate +greenlet==3.5.0 + # via sqlalchemy +idna==3.13 + # via + # email-validator + # requests +iniconfig==2.3.0 + # via pytest +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +mako==1.3.12 + # via alembic +markupsafe==3.0.3 + # via + # flask + # jinja2 + # mako + # werkzeug +marshmallow==4.3.0 + # via + # flask-marshmallow + # marshmallow-sqlalchemy +marshmallow-sqlalchemy==1.5.0 + # via -r requirements.in +mysql-connector-python==9.7.0 + # via -r requirements.in +packaging==26.2 + # via pytest +pluggy==1.6.0 + # via + # pytest + # pytest-cov +pygments==2.20.0 + # via pytest +pyjwt==2.12.1 + # via flask-jwt-extended +pymysql==1.1.3 + # via -r requirements.in +pytest==9.0.3 + # via + # -r requirements.in + # pytest-cov + # pytest-flask +pytest-cov==7.1.0 + # via -r requirements.in +pytest-flask==1.3.0 + # via -r requirements.in +python-dotenv==1.2.2 + # via -r requirements.in +requests==2.33.1 + # via -r requirements.in +sqlalchemy==2.0.49 + # via + # alembic + # flask-sqlalchemy + # marshmallow-sqlalchemy +tabulate==0.10.0 + # via -r requirements.in +typing-extensions==4.15.0 + # via + # alembic + # sqlalchemy +urllib3==2.7.0 + # via requests +werkzeug==3.1.8 + # via + # -r requirements.in + # flask + # flask-cors + # flask-jwt-extended + # pytest-flask +waitress>=3.0 +tzdata diff --git a/scripts/check-naming-and-style.sh b/scripts/check-naming-and-style.sh new file mode 100755 index 00000000..7cadfba6 --- /dev/null +++ b/scripts/check-naming-and-style.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# Pre-commit naming + style check for shopdb-flask. +# Enforces CONTRIBUTING.md rules: +# 1. No non-ASCII chars in source (em-dashes, smart quotes, arrows, emojis) +# 2. No banned shorthand identifiers (cfg, ctx, mgr, req, res, env, util, helper) +# as standalone names (suffix usage like printers_bp, request_obj is allowed) +# 3. No snake_case DB column names in __tablename__ or db.Column attrs +# 4. No snake_case API params in frontend that should match DB column names +# +# Exits non-zero if any violation found. +# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/ + +set -e + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" +cd "$REPO_ROOT" + +VIOLATIONS=0 + +EXCLUDES=( + --exclude-dir=venv + --exclude-dir=node_modules + --exclude-dir=__pycache__ + --exclude-dir=dist + --exclude-dir=.git + --exclude-dir=versions +) + +INCLUDES_CODE=( + --include='*.py' + --include='*.vue' + --include='*.js' + --include='*.ts' +) + +INCLUDES_ALL=( + --include='*.py' + --include='*.vue' + --include='*.js' + --include='*.ts' + --include='*.json' + --include='*.md' + --include='*.yaml' + --include='*.yml' +) + +echo "==> Checking for non-ASCII characters..." +NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_CODE[@]}" . 2>/dev/null || true) +if [ -n "$NON_ASCII" ]; then + echo "FAIL: non-ASCII characters found (em-dashes, smart quotes, arrows, emojis):" + echo "$NON_ASCII" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for banned shorthand (standalone)..." +# Match the word as a standalone identifier: not preceded or followed by underscore/word char +# Word boundary in grep is \b but we want to exclude suffix usage like printers_bp +# So: match (^|[^a-zA-Z0-9_])(banned)([^a-zA-Z0-9_]|$) +for word in cfg ctx mgr req res; do + HITS=$(grep -rPn "(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" "${EXCLUDES[@]}" --include='*.py' --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null \ + | grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \ + || true) + if [ -n "$HITS" ]; then + echo "FAIL: banned shorthand '$word' (standalone) found:" + echo "$HITS" + echo + VIOLATIONS=$((VIOLATIONS + 1)) + fi +done + +echo "==> Checking for snake_case DB tablenames..." +SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true) +if [ -n "$SNAKE_TABLES" ]; then + echo "FAIL: snake_case __tablename__ found (must be lowercase concatenated):" + echo "$SNAKE_TABLES" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for snake_case DB column attrs..." +SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true) +if [ -n "$SNAKE_COLS" ]; then + echo "FAIL: snake_case db.Column attribute found (must match column name, no underscores):" + echo "$SNAKE_COLS" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for snake_case ForeignKey targets..." +SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true) +if [ -n "$SNAKE_FK" ]; then + echo "FAIL: snake_case ForeignKey target found:" + echo "$SNAKE_FK" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for snake_case API params in frontend (DB-mirrored fields)..." +SNAKE_FE=$(grep -rPn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" "${EXCLUDES[@]}" --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null || true) +if [ -n "$SNAKE_FE" ]; then + echo "FAIL: snake_case API params in frontend (must match DB column names without underscores):" + echo "$SNAKE_FE" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +if [ "$VIOLATIONS" -gt 0 ]; then + echo "==================================================" + echo "$VIOLATIONS naming/style violation(s) found." + echo "See CONTRIBUTING.md for the full convention." + echo "==================================================" + exit 1 +fi + +echo "==> All naming/style checks passed." +exit 0 diff --git a/scripts/migration/README.md b/scripts/migration/README.md new file mode 100644 index 00000000..a519b3da --- /dev/null +++ b/scripts/migration/README.md @@ -0,0 +1,24 @@ +# scripts/migration (superseded) + +The direct-SQL migrators that used to live here (`migrate_assets.py`, +`migrate_communications.py`, `migrate_notifications.py`, `migrate_usb.py`, +`run_migration.py`, `verify_migration.py`) and `scripts/import_from_mysql.py` +were **removed** - they had drifted badly out of sync with the current schema +(they targeted a nonexistent `equipment` table, the retired `Machine` model, and +columns that no longer exist) and were actively misleading. + +## Use the import API + a site loader instead + +Legacy data is imported through the maintained, schema-agnostic contract in +the IMPORT-API page in the project wiki (create through real endpoints +with an admin PAT + `X-Import-Mode`). Each adopting site writes a thin loader +that reads its own source database and drives that API. + +- Reference implementation: [`scripts/site_imports/wjf/`](../site_imports/wjf/) + (West Jefferson classic-ASP -> flask). +- Adoption playbook: the IMPORT-ADOPTION page in the project wiki. + +## What's kept here + +- `fix_legacy_schema.sql` - one-time SQL fixups against a legacy source DB. +- `one-offs/` - individual one-shot SQL snippets (see its README). diff --git a/scripts/migration/__init__.py b/scripts/migration/__init__.py new file mode 100644 index 00000000..1d5670f0 --- /dev/null +++ b/scripts/migration/__init__.py @@ -0,0 +1 @@ +"""Data migration scripts for VBScript ShopDB to Flask migration.""" diff --git a/scripts/migration/fix_legacy_schema.sql b/scripts/migration/fix_legacy_schema.sql new file mode 100644 index 00000000..1ddd2068 --- /dev/null +++ b/scripts/migration/fix_legacy_schema.sql @@ -0,0 +1,88 @@ +-- ============================================================================= +-- Legacy Schema Migration Script +-- Adds missing columns to make VBScript ShopDB compatible with Flask ShopDB +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- appowners table +-- ----------------------------------------------------------------------------- +ALTER TABLE appowners + ADD COLUMN IF NOT EXISTS email VARCHAR(100) NULL, + ADD COLUMN IF NOT EXISTS phone VARCHAR(50) NULL, + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- pctypes / computertypes alignment +-- ----------------------------------------------------------------------------- +-- The Flask app uses pctype table but expects certain columns +ALTER TABLE pctype + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(); + +-- Ensure isactive is correct type +ALTER TABLE pctype MODIFY COLUMN isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- statuses table (if needed - Flask uses assetstatuses) +-- ----------------------------------------------------------------------------- +-- assetstatuses already populated earlier + +-- ----------------------------------------------------------------------------- +-- subnets table +-- ----------------------------------------------------------------------------- +ALTER TABLE subnets + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- vlans table +-- ----------------------------------------------------------------------------- +ALTER TABLE vlans + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- usbdevices table +-- ----------------------------------------------------------------------------- +ALTER TABLE usbdevices + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS isactive TINYINT(1) DEFAULT 1; + +-- ----------------------------------------------------------------------------- +-- usbcheckouts table +-- ----------------------------------------------------------------------------- +ALTER TABLE usbcheckouts + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(); + +-- ----------------------------------------------------------------------------- +-- notifications table +-- ----------------------------------------------------------------------------- +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS createddate DATETIME DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS modifieddate DATETIME DEFAULT NOW(); + +-- Copy dates from existing columns if they exist +UPDATE notifications SET createddate = startdate WHERE createddate IS NULL; +UPDATE notifications SET modifieddate = startdate WHERE modifieddate IS NULL; + +-- ----------------------------------------------------------------------------- +-- Verify key counts +-- ----------------------------------------------------------------------------- +SELECT 'Migration complete. Record counts:' as status; +SELECT 'vendors' as tbl, COUNT(*) as cnt FROM vendors +UNION ALL SELECT 'models', COUNT(*) FROM models +UNION ALL SELECT 'machinetypes', COUNT(*) FROM machinetypes +UNION ALL SELECT 'operatingsystems', COUNT(*) FROM operatingsystems +UNION ALL SELECT 'businessunits', COUNT(*) FROM businessunits +UNION ALL SELECT 'applications', COUNT(*) FROM applications +UNION ALL SELECT 'machines', COUNT(*) FROM machines +UNION ALL SELECT 'printers', COUNT(*) FROM printers +UNION ALL SELECT 'assets', COUNT(*) FROM assets +UNION ALL SELECT 'knowledgebase', COUNT(*) FROM knowledgebase +UNION ALL SELECT 'notifications', COUNT(*) FROM notifications; diff --git a/scripts/migration/one-offs/README.md b/scripts/migration/one-offs/README.md new file mode 100644 index 00000000..67f2c832 --- /dev/null +++ b/scripts/migration/one-offs/README.md @@ -0,0 +1,16 @@ +# One-off migration scripts (historical) + +These are hand-run SQL scripts kept for reference. They are NOT part of the +Alembic chain (`migrations/versions/`) and are not run by `flask db upgrade`. A +fresh install never needs them; they exist for databases that predate the +corresponding change. + +- `add_recertification_type.sql` - inserts the blue "Recertification" + notification type into an already-existing database. Historical: the + notifications plugin now seeds this type on install + (`plugins/notifications/plugin.py`), so any fresh install already has it. Only + a pre-existing DB that was never re-installed needs this script. Idempotent. + +A related one-off, widening `notifications.employeesso` / `employeename` to +TEXT, has been removed because it is fully superseded by Alembic migration +`7d02_widen_notification_employee_cols`. diff --git a/scripts/migration/one-offs/add_recertification_type.sql b/scripts/migration/one-offs/add_recertification_type.sql new file mode 100644 index 00000000..4bec0ea6 --- /dev/null +++ b/scripts/migration/one-offs/add_recertification_type.sql @@ -0,0 +1,21 @@ +-- Recertification notification type (shopdb-flask) +-- +-- Employee-photo type (blue, like recognition) for employees who must retake a +-- training course. The shopfloor feed splits it one card per employee and the +-- dashboard shows it in a blue "Recertification Required" carousel; the display +-- window is two weeks (computed server-side in the notifications API). +-- +-- Idempotent - safe to re-run. The notifications plugin also seeds this type on +-- install (plugins/notifications/plugin.py); this file is for databases that +-- already exist and are not re-installed. + +INSERT INTO notificationtypes (typename, typedescription, typecolor, isactive) +SELECT * FROM ( + SELECT 'Recertification' AS typename, + 'Employees due to retake a training course (shows two weeks)' AS typedescription, + 'recertification' AS typecolor, + 1 AS isactive +) AS t +WHERE NOT EXISTS ( + SELECT 1 FROM notificationtypes WHERE typename = 'Recertification' +); diff --git a/scripts/site_imports/__init__.py b/scripts/site_imports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/site_imports/wjf/README.md b/scripts/site_imports/wjf/README.md new file mode 100644 index 00000000..0a2a9f09 --- /dev/null +++ b/scripts/site_imports/wjf/README.md @@ -0,0 +1,48 @@ +# WJ classic-ASP import loader (reference implementation) + +This is **site-specific reference glue**, not product code. It maps West +Jefferson's classic-ASP `shopdb` / `cmmc_usb` / `wjf_employees` schema onto the +maintained, schema-agnostic import contract in the IMPORT-API wiki page. + +**Every adopting site has its own source database.** Nobody else runs this +loader. Instead, copy the pattern: + +1. Point the harness at your source DB(s) (edit `harness.Source`). +2. Write per-entity stages that read your tables and POST to the same + the IMPORT-API wiki page endpoints with `Authorization: Bearer ` and + `X-Import-Mode: true`. +3. Persist legacy-id -> new-id crosswalks (see `harness.IdMap`) so later stages + resolve foreign keys and a crashed run resumes. + +The import API is the stable contract; loaders are per-site. The mapping itself +is the bulk of the work: enumerate the source tables against this contract and +write one stage per entity (this loader is the worked example). + +## Running (against a THROWAWAY import database) + +```bash +DATABASE_URL='mysql+pymysql://root:PW@127.0.0.1:3306/shopdb_flask_import?charset=utf8mb4' \ + venv/bin/python -m scripts.site_imports.wjf.run --stages reference,employees +``` + +Prereqs: a fresh target DB built with `flask db upgrade` + `flask plugin +upgrade-all` + `flask seed permissions/settings/reference-data`, and the three +source dumps loaded into scratch DBs (`shopdb_src`, `cmmc_usb_src`, +`wjf_employees_src`). See `scratchpad/IMPORT-PLAN.md` for the full mapping, +resolved decisions, and remaining stages. + +## Status + +Complete - all 15 stages built and verified end-to-end against a fresh scratch +target (zero endpoint errors): `reference`, `employees`, `catalog`, `assets` +(the hub + machineid->assetid crosswalk), `locations`, `printers`, +`communications`, `applications` (+ installs), `warranties`, `notifications`, +`knowledgebase`, `relationships`, `subnets`, `usb`, `verify`. + +Last full run: 983 assets (computer 663, machine 76, network 58, measuring-tool +136, printer 50), 24 locations, 415 employees, 850 installs, 461 primary IPs, +464 warranties, 261 notifications, 341 KB, 93 relationships, 37 subnets, 18 USB +devices + 232 events. UI spot-check passed (computer list + shop-floor map). + +Known follow-ups: general asset `locationid` is null (no source column outside +printers); the ~146 duplicate machinenumbers are first-wins-skipped by decision. diff --git a/scripts/site_imports/wjf/__init__.py b/scripts/site_imports/wjf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/site_imports/wjf/harness.py b/scripts/site_imports/wjf/harness.py new file mode 100644 index 00000000..abb5b830 --- /dev/null +++ b/scripts/site_imports/wjf/harness.py @@ -0,0 +1,177 @@ +"""Import harness for the WJ classic-ASP -> flask reference loader. + +This is SITE-SPECIFIC reference glue, NOT product code. It maps West Jefferson's +classic schema onto the maintained, schema-agnostic import contract in +the IMPORT-API wiki page. Other sites copy this pattern against their own source DB; +they do not run this loader. + +The harness builds the flask app against the current DATABASE_URL (point it at a +throwaway import database), mints an unscoped admin PAT in-process, and drives +the REAL import endpoints through the app test client with the X-Import-Mode +header - so it exercises the same routes/authz/validation an HTTP client would, +without needing a running server. Legacy-id -> new-id maps persist to JSON so a +crashed run resumes and later stages resolve foreign keys. +""" + +import json +import os +import pymysql + +from shopdb import create_app +from shopdb.extensions import db + + +def _source_password(): + """Root password for the scratch source DBs: reuse the dev DATABASE_URL in + .env unless SOURCE_DB_PASSWORD overrides.""" + if os.environ.get('SOURCE_DB_PASSWORD'): + return os.environ['SOURCE_DB_PASSWORD'] + env = os.path.join(os.path.dirname(__file__), '..', '..', '..', '.env') + with open(os.path.abspath(env)) as handle: + for line in handle: + if line.startswith('DATABASE_URL='): + after = line.split('root:', 1)[1] + return after.split('@', 1)[0] + raise RuntimeError('could not resolve source DB password') + + +class Source: + """Read-only access to the three scratch source databases.""" + + def __init__(self, host='127.0.0.1', port=3306, user='root', password=None): + self.password = password or _source_password() + self.host, self.port, self.user = host, port, user + self._conns = {} + + def db(self, name): + if name not in self._conns: + self._conns[name] = pymysql.connect( + host=self.host, port=self.port, user=self.user, + password=self.password, database=name, charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor) + return self._conns[name] + + def rows(self, dbname, sql, args=None): + with self.db(dbname).cursor() as cur: + cur.execute(sql, args or ()) + return cur.fetchall() + + +class IdMap: + """Persisted legacy-id -> new-id crosswalks, one namespace per entity.""" + + def __init__(self, path): + self.path = path + self._data = {} + if os.path.exists(path): + with open(path) as handle: + self._data = json.load(handle) + + def put(self, namespace, legacy, new): + self._data.setdefault(namespace, {})[str(legacy)] = new + self.save() + + def get(self, namespace, legacy, default=None): + return self._data.get(namespace, {}).get(str(legacy), default) + + def has(self, namespace, legacy): + return str(legacy) in self._data.get(namespace, {}) + + def count(self, namespace): + return len(self._data.get(namespace, {})) + + def save(self): + with open(self.path, 'w') as handle: + json.dump(self._data, handle, indent=1) + + +class Harness: + """App + admin PAT + import-mode test client + id maps.""" + + def __init__(self, idmap_path=None): + self.app = create_app() + self.appcontext = self.app.app_context() + self.appcontext.push() + self._silence_sql_logging() + self.client = self.app.test_client() + self._ensure_asset_types() + self.secret = self._mint_admin_pat() + default = os.path.join(os.path.dirname(__file__), 'idmap.json') + self.ids = IdMap(idmap_path or default) + self.source = Source() + self.errors = [] + + def _silence_sql_logging(self): + import logging + logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING) + + def _ensure_asset_types(self): + """Register each plugin's AssetType + seeded subtypes. on_install (which + does this) is skipped when a DB is built with `flask plugin upgrade-all` + rather than a fresh `install`, so run it idempotently here - the create + routes 500 without the AssetType row.""" + manager = self.app.extensions.get('plugin_manager') + if not manager: + return + for _name, plugin in manager.get_all_plugins().items(): + try: + plugin.on_install(self.app) + except Exception: + pass + # A full import IS the setup, so mark it complete - otherwise the admin + # is bounced to the first-run wizard on every fresh import DB. + from shopdb.core.models import Setting + Setting.set('setup_complete', 'true', valuetype='boolean', category='site') + db.session.commit() + + def _mint_admin_pat(self): + """Create (or reuse) an admin user and an unscoped admin PAT. Unscoped + because import mode + role gates require full owner authority + (the IMPORT-API wiki page). Returns the clear secret.""" + from werkzeug.security import generate_password_hash + from shopdb.core.models import User, Role, ApiToken + admin = User.query.join(User.roles).filter(Role.rolename == 'admin').first() + if not admin: + role = Role.query.filter_by(rolename='admin').first() \ + or Role(rolename='admin', description='Administrator') + db.session.add(role) + admin = User(username='importer', email='importer@localhost', + passwordhash=generate_password_hash('x'), isactive=True) + admin.roles.append(role) + db.session.add(admin) + db.session.commit() + secret = ApiToken.generate_secret() + token = ApiToken(userid=admin.userid, name='wjf legacy import', + tokenprefix=ApiToken.prefix_of(secret), + tokenhash=ApiToken.hash_secret(secret), scopes=None) + db.session.add(token) + db.session.commit() + return secret + + def _headers(self, import_mode): + # A PAT authenticates as Authorization: Bearer shopdb_pat_... (the + # before_request shim resolves it into a JWT identity). X-API-Key is for + # collector/service tokens, not personal tokens. + headers = {'Authorization': f'Bearer {self.secret}'} + if import_mode: + headers['X-Import-Mode'] = 'true' + return headers + + def post(self, path, payload, import_mode=True): + """POST to an import endpoint. Returns (status, data). 409s are returned + (idempotency handled by the caller), other 4xx/5xx are recorded.""" + resp = self.client.post(path, json=payload, headers=self._headers(import_mode)) + body = resp.get_json() or {} + data = body.get('data', body) + if resp.status_code >= 400 and resp.status_code != 409: + self.errors.append((path, resp.status_code, payload, data)) + return resp.status_code, data + + def get(self, path): + resp = self.client.get(path, headers=self._headers(False)) + body = resp.get_json() or {} + return resp.status_code, body.get('data', body) + + def close(self): + self.ids.save() + self.appcontext.pop() diff --git a/scripts/site_imports/wjf/run.py b/scripts/site_imports/wjf/run.py new file mode 100644 index 00000000..fa88ec57 --- /dev/null +++ b/scripts/site_imports/wjf/run.py @@ -0,0 +1,817 @@ +"""WJ classic-ASP -> flask reference import loader (site-specific). + +Run against a THROWAWAY import database: + + DATABASE_URL='mysql+pymysql://root:PW@127.0.0.1:3306/shopdb_flask_import?charset=utf8mb4' \\ + venv/bin/python -m scripts.site_imports.wjf.run --stages reference,employees + +Stages are idempotent (re-running resolves existing rows by natural key), so a +crashed run resumes. See scripts/site_imports/wjf/harness.py for the contract +this drives, and scratchpad/IMPORT-PLAN.md for the full mapping + decisions. + +Implemented: reference (vendors, businessunits, operatingsystems), employees. +TODO stages (assets hub + dependents + network + usb) are stubbed - they need +the machineid->assetid crosswalk this harness persists. +""" + +import argparse + +from .harness import Harness + + +def _id_of(row, idfield): + if not isinstance(row, dict): + return None + if row.get(idfield) is not None: + return row[idfield] + # asset-create responses nest the id at the top level (assetid) too + for key in (idfield, 'id', 'assetid'): + if row.get(key) is not None: + return row[key] + return None + + +def _upsert(h, path, payload, unique_field, idfield, list_path=None): + """POST payload; on 409 resolve the existing row by its unique field. + Returns the new/existing id, or None.""" + status, data = h.post(path, payload) + if status in (200, 201): + return _id_of(data, idfield) + if status == 409: + lookup = list_path or path + lookup += ('&' if '?' in lookup else '?') + 'per_page=10000' + _, rows = h.get(lookup) + items = rows if isinstance(rows, list) else rows.get('items', rows) + for row in (items or []): + if str(row.get(unique_field, '')).strip().lower() == \ + str(payload[unique_field]).strip().lower(): + return _id_of(row, idfield) + return None + + +# --- classic machinetype routing (per the resolved import decisions) --------- +# measuringtools are the physical instruments (the CMM/gauge machine types). A +# PC that DRIVES one (pctype CMM/Genspect/Keyence/Wax) is still a computer, not a +# measuring tool - routing by pctype wrongly swept ~105 PCs into measuringtools. +MEASURING_MTYPES = {3, 5, 8, 23, 47, 48} # CMM, Wax, Eddy, Measuring, Inspection, Spline +COMPUTER_MTYPES = {33, 20} # PC, Server +NETWORK_MTYPES = {16, 17, 18, 19, 46} # Access, IDF, Camera, Switch, Firewall +SKIP_MTYPES = {15, 44} # Printer (printers table), USB (cmmc source) +# LocationOnly is the islocationonly BIT, not machinetypeid=1: of the 158 type-1 +# rows only 24 carry the bit (real named areas). The other 134 are active, +# modelled shop machines just left untyped - they must become assets, not +# Locations. + + +def _truthy_bit(value): + """MySQL bit(1) comes back from pymysql as bytes b'\\x00'/b'\\x01'.""" + return value not in (0, None, False, b'\x00', b'', '0', '') +# classic controllertypeid -> (vendor, model) hand split; 1=TBD dropped +CONTROLLER_SPLIT = {2: ('Fanuc', '31i-MB'), 6: ('Fanuc', None), + 7: ('Okuma', None), 8: ('Makino', None)} + + +def _route(machinetypeid, pctypeid, islocationonly=False): + if islocationonly: + return 'location' + if machinetypeid in SKIP_MTYPES: + return 'skip' + if machinetypeid in NETWORK_MTYPES: + return 'network' + if machinetypeid in MEASURING_MTYPES: + return 'measuringtool' + if machinetypeid in COMPUTER_MTYPES: + return 'computer' # a metrology PC is still a PC (it CONTROLS a tool) + return 'machine' # includes the 134 untyped (machinetypeid=1) shop machines + + +def _modeltype_category(machinetypeid): + if machinetypeid in COMPUTER_MTYPES: + return 'PC' + if machinetypeid in NETWORK_MTYPES: + return 'Network' + if machinetypeid == 15: + return 'Printer' + return 'Equipment' + + +def stage_reference(h): + """Seed the reference tier that later FKs resolve against, capturing + legacy-id -> new-id crosswalks.""" + counts = {} + + vendors = h.source.rows('shopdb_src', + 'SELECT vendorid, vendor FROM vendors WHERE isactive=1') + for v in vendors: + newid = _upsert(h, '/api/vendors', {'vendor': v['vendor']}, 'vendor', 'vendorid') + if newid: + h.ids.put('vendor', v['vendorid'], newid) + counts['vendors'] = h.ids.count('vendor') + + bus = h.source.rows('shopdb_src', + 'SELECT businessunitid, businessunit FROM businessunits WHERE isactive=1') + for b in bus: + newid = _upsert(h, '/api/businessunits', {'businessunit': b['businessunit']}, + 'businessunit', 'businessunitid') + if newid: + h.ids.put('businessunit', b['businessunitid'], newid) + counts['businessunits'] = h.ids.count('businessunit') + + oses = h.source.rows('shopdb_src', + 'SELECT osid, operatingsystem FROM operatingsystems') + for o in oses: + name = (o['operatingsystem'] or '').strip() + if not name: + continue + newid = _upsert(h, '/api/operatingsystems', {'osname': name}, 'osname', 'osid') + if newid: + h.ids.put('os', o['osid'], newid) + counts['operatingsystems'] = h.ids.count('os') + + return counts + + +def stage_employees(h): + """Bulk-upsert the employee directory (selfhosted). Photos are deferred + (per the import decisions); occurrences are out of scope.""" + from shopdb.core.models import Setting + Setting.set('employee_directory_mode', 'selfhosted', valuetype='string', + category='employees') + from shopdb.extensions import db + db.session.commit() + + rows = h.source.rows('wjf_employees_src', + 'SELECT SSO, First_Name, Last_Name, Team, Role FROM employees') + lines = ['SSO,First_Name,Last_Name,Team,Role'] + for r in rows: + vals = [str(r['SSO']), (r['First_Name'] or '').strip(), + (r['Last_Name'] or '').strip(), (r['Team'] or '').strip(), + (r['Role'] or '').strip()] + lines.append(','.join(v.replace(',', ' ') for v in vals)) + status, data = h.post('/api/employees/directory/import', {'csv': '\n'.join(lines)}) + return {'employees_posted': len(rows), 'result': data} + + +def stage_catalog(h): + """Seed the type catalogs the asset routing + models depend on, capturing + crosswalks: modeltypes + per-plugin subtypes (from classic machinetypes), + computer subtypes (from pctype), controller vendor/model split, models.""" + counts = {} + mtypes = h.source.rows('shopdb_src', + 'SELECT machinetypeid, machinetype FROM machinetypes') + for mt in mtypes: + name = (mt['machinetype'] or '').strip() + mtid = mt['machinetypeid'] + # machinetypeid=1 (LocationOnly) is not a real machine subtype - the + # untyped shop machines that carry it import with a null subtype. + if not name or mtid == 1: + continue + # modeltype (types the models catalog) + modeltypeid = _upsert(h, '/api/modeltypes', + {'modeltype': name, 'category': _modeltype_category(mtid)}, + 'modeltype', 'modeltypeid') + if modeltypeid: + h.ids.put('modeltype', mtid, modeltypeid) + # per-plugin asset subtype, by route + route = _route(mtid, None) + if route == 'machine': + sid = _upsert(h, '/api/machines/types', {'machinetype': name}, + 'machinetype', 'machinetypeid', '/api/machines/types') + if sid: + h.ids.put('machinesubtype', mtid, sid) + elif route == 'network': + sid = _upsert(h, '/api/network/types', {'networkdevicetype': name}, + 'networkdevicetype', 'networkdevicetypeid', '/api/network/types') + if sid: + h.ids.put('networksubtype', mtid, sid) + elif route == 'measuringtool': + sid = _upsert(h, '/api/measuringtools/types', {'name': name}, + 'name', 'measuringtooltypeid', '/api/measuringtools/types') + if sid: + h.ids.put('measuringsubtype', mtid, sid) + counts['modeltypes'] = h.ids.count('modeltype') + + # computer subtypes from pctype + for pc in h.source.rows('shopdb_src', 'SELECT pctypeid, typename FROM pctype'): + name = (pc['typename'] or '').strip() + if not name: + continue + sid = _upsert(h, '/api/computers/types', {'computertype': name}, + 'computertype', 'computertypeid', '/api/computers/types') + if sid: + h.ids.put('computertype', pc['pctypeid'], sid) + counts['computertypes'] = h.ids.count('computertype') + + # controller split -> vendor + model, crosswalk controllertypeid -> both ids + for ctid, (vendor, model) in CONTROLLER_SPLIT.items(): + vid = _upsert(h, '/api/vendors', {'vendor': vendor}, 'vendor', 'vendorid') + mid = None + if model and vid: + mid = _upsert(h, '/api/models', {'modelnumber': model, 'vendorid': vid}, + 'modelnumber', 'modelnumberid') + h.ids.put('controllervendor', ctid, vid) + if mid: + h.ids.put('controllermodel', ctid, mid) + + # models catalog (the only vendor source for machines) + for m in h.source.rows('shopdb_src', + 'SELECT modelnumberid, modelnumber, vendorid, machinetypeid ' + 'FROM models WHERE isactive=1'): + number = (m['modelnumber'] or '').strip() + if not number: + continue + payload = {'modelnumber': number, + 'vendorid': h.ids.get('vendor', m['vendorid']), + 'modeltypeid': h.ids.get('modeltype', m['machinetypeid'])} + newid = _upsert(h, '/api/models', payload, 'modelnumber', 'modelnumberid') + if newid: + h.ids.put('model', m['modelnumberid'], newid) + counts['models'] = h.ids.count('model') + return counts + + +def stage_assets(h): + """The hub: fan classic machines out to the right asset endpoint by type, + persisting the machineid -> assetid crosswalk everything downstream needs. + LocationOnly/printer/usb rows are skipped here (handled elsewhere / TODO).""" + # isactive=1 only: retired machines (classic keeps them as history) must not + # land as live assets. Every other stage already filters isactive=1; the + # assets hub is the one that leaked retired rows (incl. G-prefix hostnames). + machines = h.source.rows('shopdb_src', 'SELECT * FROM machines WHERE isactive=1') + seen_assetnumbers = set() + counts = {'computer': 0, 'machine': 0, 'network': 0, 'measuringtool': 0, + 'skip_location': 0, 'skip_other': 0, 'skip_dup': 0, 'skip_9999': 0} + + # Pre-seed assetnumbers already in the target (idempotent re-run). + for route in ('computers', 'machines', 'network', 'measuringtools'): + _, rows = h.get(f'/api/{route}?per_page=5000') + for row in (rows.get('items', rows) if isinstance(rows, dict) else rows) or []: + if isinstance(row, dict) and row.get('assetnumber'): + seen_assetnumbers.add(row['assetnumber'].strip().lower()) + + # On a duplicate machinenumber, first-wins - so process the richer asset + # types first (a PC carries installs/IP a bare untyped machine does not). + route_rank = {'computer': 0, 'measuringtool': 1, 'network': 2, 'machine': 3} + machines = sorted(machines, key=lambda m: route_rank.get( + _route(m['machinetypeid'], m['pctypeid'], _truthy_bit(m['islocationonly'])), 4)) + + for m in machines: + route = _route(m['machinetypeid'], m['pctypeid'], + _truthy_bit(m['islocationonly'])) + if route == 'location': + counts['skip_location'] += 1 + continue + if route == 'skip': + counts['skip_other'] += 1 + continue + + raw = (m['machinenumber'] or '').strip() + if raw == '9999': + counts['skip_9999'] += 1 + continue + assetnumber = raw or (m['hostname'] or '').strip() + if not assetnumber: + counts['skip_other'] += 1 + continue + key = assetnumber.lower() + if key in seen_assetnumbers: + counts['skip_dup'] += 1 + continue + seen_assetnumbers.add(key) + + base = { + 'assetnumber': assetnumber, + 'name': (m['alias'] or m['hostname'] or '').strip() or None, + 'serialnumber': (m['serialnumber'] or '').strip() or None, + 'businessunitid': h.ids.get('businessunit', m['businessunitid']), + 'mapx': m['mapleft'], 'mapy': m['maptop'], + 'notes': m['machinenotes'], + 'dateadded': str(m['dateadded']) if m['dateadded'] else None, + 'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None, + } + if route == 'computer': + path = '/api/computers' + payload = {**base, 'hostname': (m['hostname'] or '').strip() or None, + 'computertypeid': h.ids.get('computertype', m['pctypeid']), + 'osid': h.ids.get('os', m['osid']), + 'ipaddress': (m['ipaddress1'] or '').strip() or None} + elif route == 'network': + path = '/api/network' + payload = {**base, 'hostname': (m['hostname'] or m['fqdn'] or '').strip() or None, + 'networkdevicetypeid': h.ids.get('networksubtype', m['machinetypeid']), + 'vendorid': None, + 'ipaddress': (m['ipaddress1'] or '').strip() or None} + elif route == 'measuringtool': + path = '/api/measuringtools' + payload = {**base, + 'measuringtooltypeid': h.ids.get('measuringsubtype', m['machinetypeid'])} + else: # machine + path = '/api/machines' + payload = {**base, 'machinetypeid': h.ids.get('machinesubtype', m['machinetypeid']), + 'modelnumberid': h.ids.get('model', m['modelnumberid']), + 'vendorid': None, + 'controllervendorid': h.ids.get('controllervendor', m['controllertypeid']), + 'controllermodelid': h.ids.get('controllermodel', m['controllertypeid'])} + + status, data = h.post(path, payload) + if status in (200, 201): + assetid = _id_of(data, 'assetid') + if assetid: + h.ids.put('asset', m['machineid'], assetid) + counts[route] += 1 + return counts + + +def stage_printers(h): + """Printers come from the printers TABLE (not the machines hub). assetnumber + is synthesized PRN-{printerid} (no machinenumber). The printer's IP folds + into a Communication via the create route. Location resolves from the host + machineid when it is one of the LocationOnly rows. Skips inactive.""" + made = 0 + for p in h.source.rows('shopdb_src', + 'SELECT printerid, modelid, printerwindowsname, printercsfname, ' + 'serialnumber, fqdn, ipaddress, machineid, maptop, mapleft, iscsf, ' + 'installpath, printernotes, printerpin FROM printers WHERE isactive=1'): + payload = { + 'assetnumber': f"PRN-{p['printerid']}", + 'name': (p['printerwindowsname'] or '').strip() or None, + 'serialnumber': (p['serialnumber'] or '').strip() or None, + 'hostname': (p['fqdn'] or '').strip() or None, + 'windowsname': (p['printerwindowsname'] or '').strip() or None, + 'sharename': (p['printercsfname'] or '').strip() or None, + 'ipaddress': (p['ipaddress'] or '').strip() or None, + 'iscsf': _truthy_bit(p['iscsf']), + 'installpath': p['installpath'], 'pin': p['printerpin'], + 'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'], + 'modelnumberid': h.ids.get('model', p['modelid']), + 'locationid': h.ids.get('location', p['machineid']), + } + status, _ = h.post('/api/printers', payload) + if status in (200, 201): + made += 1 + return {'printers': made} + + +# classic pctype -> the measuring instrument that PC drives +METROLOGY_PCTYPE_TOOL = {5: 'CMM', 6: 'Form Tracer', 7: 'Vision System', 8: 'Genspect'} + + +def stage_metrology(h): + """Metrology PCs (CMM/Genspect/Keyence/Wax) are computers that CONTROL an + instrument. Classic has no separate tool row, so synthesize a measuring-tool + asset per metrology PC and a Controls relationship (PC -> tool), mirroring + what the runtime collector does. Runs after the asset hub (needs the PC's + assetid from the crosswalk).""" + controls_id = _upsert(h, '/api/assets/relationshiptypes', + {'relationshiptype': 'Controls'}, 'relationshiptype', + 'relationshiptypeid', '/api/assets/relationshiptypes') + made = linked = 0 + for m in h.source.rows('shopdb_src', + 'SELECT machineid, alias, hostname, machinenumber, businessunitid, ' + 'mapleft, maptop, pctypeid FROM machines ' + 'WHERE machinetypeid=33 AND pctypeid IN (5,6,7,8) ' + 'AND isactive=1 ' + 'AND (islocationonly IS NULL OR islocationonly=0)'): + pc_assetid = h.ids.get('asset', m['machineid']) + if not pc_assetid: + continue # PC not imported (dup-skipped etc.) + toolname = METROLOGY_PCTYPE_TOOL[m['pctypeid']] + typeid = _upsert(h, '/api/measuringtools/types', {'name': toolname}, + 'name', 'measuringtooltypeid', '/api/measuringtools/types') + pcname = (m['alias'] or m['hostname'] or m['machinenumber'] or '').strip() + status, data = h.post('/api/measuringtools', { + 'assetnumber': f"MT-{m['machineid']}", + 'name': (f"{pcname} {toolname}").strip() or toolname, + 'measuringtooltypeid': typeid, + 'businessunitid': h.ids.get('businessunit', m['businessunitid']), + 'mapx': m['mapleft'], 'mapy': m['maptop']}) + if status not in (200, 201): + continue + tool_assetid = _id_of(data, 'assetid') + made += 1 + if tool_assetid and controls_id: + h.ids.put('metrologytool', m['machineid'], tool_assetid) + st, _ = h.post('/api/assets/relationships', { + 'sourceassetid': pc_assetid, 'targetassetid': tool_assetid, + 'relationshiptypeid': controls_id}) + if st in (200, 201): + linked += 1 + return {'measuringtools_synth': made, 'controls_links': linked} + + +def stage_communications(h): + """Fold each asset's primary IP from the source communications table. There + is no bulk-communications endpoint, so this is one of the plan's documented + direct-ORM gaps (the API only takes a primary IP on asset create/update).""" + from shopdb.core.models import Communication, CommunicationType + from shopdb.extensions import db + ip_type = CommunicationType.query.filter_by(comtype='IP').first() + rows = h.source.rows('shopdb_src', + 'SELECT machineid, address FROM communications ' + 'WHERE comstypeid=1 AND isprimary=1') + added = 0 + for r in rows: + assetid = h.ids.get('asset', r['machineid']) + ip = (r['address'] or '').strip() + if not (assetid and ip and ip_type): + continue + if Communication.query.filter_by(assetid=assetid, isprimary=True).first(): + continue + db.session.add(Communication(assetid=assetid, comtypeid=ip_type.comtypeid, + ipaddress=ip, isprimary=True)) + added += 1 + db.session.commit() + return {'primary_ips': added} + + +def stage_applications(h): + """Support teams + applications catalog + versions + installs. Skips + inactive rows (decision); dedup of colliding app names is automatic via the + unique-appname 409-resolve.""" + from plugins.computers.models import Computer + counts = {} + + for t in h.source.rows('shopdb_src', + 'SELECT supporteamid, teamname, teamurl FROM supportteams WHERE isactive=1'): + name = (t['teamname'] or '').strip() + if not name: + continue + newid = _upsert(h, '/api/supportteams', {'teamname': name, 'teamurl': t['teamurl']}, + 'teamname', 'supportteamid', '/api/supportteams') + if newid: + h.ids.put('supportteam', t['supporteamid'], newid) + counts['supportteams'] = h.ids.count('supportteam') + + for a in h.source.rows('shopdb_src', + 'SELECT appid, appname, appdescription, supportteamid, ' + 'isinstallable, applicationnotes, installpath, applicationlink, ' + 'documentationpath FROM applications WHERE isactive=1'): + name = (a['appname'] or '').strip() + if not name: + continue + payload = {'appname': name, 'appdescription': a['appdescription'], + 'supportteamid': h.ids.get('supportteam', a['supportteamid']), + 'isinstallable': bool(a['isinstallable']), + 'applicationnotes': a['applicationnotes'], 'installpath': a['installpath'], + 'applicationlink': a['applicationlink'], + 'documentationpath': a['documentationpath']} + newid = _upsert(h, '/api/applications', payload, 'appname', 'appid', + '/api/applications') + if newid: + h.ids.put('app', a['appid'], newid) + counts['applications'] = h.ids.count('app') + + for v in h.source.rows('shopdb_src', + 'SELECT appversionid, appid, version, releasedate, notes, dateadded ' + 'FROM appversions WHERE isactive=1'): + appid = h.ids.get('app', v['appid']) + ver = (v['version'] or '').strip() + if not (appid and ver): + continue + status, data = h.post(f'/api/applications/{appid}/versions', + {'version': ver, 'releasedate': str(v['releasedate']) if v['releasedate'] else None, + 'notes': v['notes'], 'dateadded': str(v['dateadded']) if v['dateadded'] else None}) + if status in (200, 201): + h.ids.put('appversion', v['appversionid'], _id_of(data, 'appversionid')) + counts['appversions'] = h.ids.count('appversion') + + installed = 0 + for i in h.source.rows('shopdb_src', + 'SELECT appid, appversionid, machineid FROM installedapps WHERE isactive=1'): + assetid = h.ids.get('asset', i['machineid']) + appid = h.ids.get('app', i['appid']) + if not (assetid and appid): + continue + computer = Computer.query.filter_by(assetid=assetid).first() + if not computer: + continue + payload = {'appid': appid} + vid = h.ids.get('appversion', i['appversionid']) + if vid: + payload['appversionid'] = vid + status, _ = h.post(f'/api/computers/{computer.computerid}/apps', payload) + if status in (200, 201): + installed += 1 + counts['installs'] = installed + return counts + + +def stage_warranties(h): + """Warranties -> POST /api/warranty, linked to the asset via the crosswalk. + The source has no vendor; hardcode Dell (decision).""" + linked = 0 + for w in h.source.rows('shopdb_src', + 'SELECT machineid, warrantyname, enddate, servicelevel, dateadded ' + 'FROM warranties'): + assetid = h.ids.get('asset', w['machineid']) + if not assetid: + continue + payload = {'vendor': 'Dell', 'provider': 'manual', + 'servicelevel': (w['servicelevel'] or '').strip() or None, + 'enddate': str(w['enddate']) if w['enddate'] else None, + 'assetids': [assetid], + 'dateadded': str(w['dateadded']) if w['dateadded'] else None} + status, _ = h.post('/api/warranty', payload) + if status in (200, 201): + linked += 1 + return {'warranties': linked} + + +def stage_notifications(h): + """Notification types + notifications.""" + counts = {} + for t in h.source.rows('shopdb_src', + 'SELECT notificationtypeid, typename, typecolor FROM notificationtypes WHERE isactive=1'): + name = (t['typename'] or '').strip() + if not name: + continue + newid = _upsert(h, '/api/notifications/types', + {'typename': name, 'typecolor': (t['typecolor'] or '').strip() or None}, + 'typename', 'notificationtypeid', '/api/notifications/types') + if newid: + h.ids.put('notificationtype', t['notificationtypeid'], newid) + counts['notificationtypes'] = h.ids.count('notificationtype') + + # SSO -> "First Last" from the employee source, so recognition/training + # notifications display a name, not a bare SSO (the model shows employeename + # or falls back to employeesso). + ssoname = {} + for e in h.source.rows('wjf_employees_src', + 'SELECT SSO, First_Name, Last_Name FROM employees'): + full = f"{(e['First_Name'] or '').strip()} {(e['Last_Name'] or '').strip()}".strip() + if full: + ssoname[str(e['SSO']).strip()] = full + + def _names(employeesso): + if not employeesso: + return None + parts = [ssoname.get(s.strip()) for s in str(employeesso).split(',') if s.strip()] + parts = [p for p in parts if p] + return ', '.join(parts) or None + + made = 0 + for n in h.source.rows('shopdb_src', + 'SELECT notificationtypeid, businessunitid, appid, notification, ' + 'starttime, endtime, ticketnumber, link, isshopfloor, employeesso ' + 'FROM notifications WHERE isactive=1'): + text = (n['notification'] or '').strip() + if not text: + continue + endtime = str(n['endtime']) if n['endtime'] else None + if endtime and endtime.startswith('2099'): + endtime = None + payload = {'notification': text, + 'notificationtypeid': h.ids.get('notificationtype', n['notificationtypeid']), + 'businessunitid': h.ids.get('businessunit', n['businessunitid']), + 'appid': h.ids.get('app', n['appid']), + 'starttime': str(n['starttime']) if n['starttime'] else None, + 'endtime': endtime, 'ticketnumber': n['ticketnumber'], 'link': n['link'], + 'isshopfloor': bool(n['isshopfloor']), 'employeesso': n['employeesso'], + 'employeename': _names(n['employeesso'])} + status, _ = h.post('/api/notifications', payload) + if status in (200, 201): + made += 1 + counts['notifications'] = made + return counts + + +def stage_knowledgebase(h): + """KB articles. appid resolves through the applications name map (topics + folded into applications).""" + made = 0 + for k in h.source.rows('shopdb_src', + 'SELECT shortdescription, keywords, appid, linkurl, notes ' + 'FROM knowledgebase WHERE isactive=1'): + title = (k['shortdescription'] or '').strip() + url = (k['linkurl'] or '').strip() + if not (title and url): + continue + payload = {'shortdescription': title, 'linkurl': url, + 'keywords': k['keywords'], 'notes': k['notes'], + 'appid': h.ids.get('app', k['appid'])} + status, _ = h.post('/api/knowledgebase', payload) + if status in (200, 201): + made += 1 + return {'kb_articles': made} + + +def stage_locations(h): + """The 24 islocationonly rows -> core Locations. Crosswalk machineid -> + locationid (relationships can point at them).""" + made = 0 + for m in h.source.rows('shopdb_src', + 'SELECT machineid, machinenumber, alias FROM machines ' + 'WHERE machinetypeid=1 AND islocationonly=1 AND isactive=1'): + name = (m['alias'] or m['machinenumber'] or '').strip() + if not name: + continue + newid = _upsert(h, '/api/locations', {'locationname': name}, + 'locationname', 'locationid', '/api/locations') + if newid: + h.ids.put('location', m['machineid'], newid) + made += 1 + return {'locations': made} + + +def stage_relationships(h): + """Active machinerelationships -> asset relationships via the crosswalk. + Edges whose endpoints did not become assets (locations/skipped/dups) drop. + Dedups on the (source, target, type) triple.""" + # Seed relationship types (case-insensitive collation folds Controls onto + # the seeded 'controls', etc), capturing the crosswalk. A "Controlled By" + # type is the reverse of "Controls" - flag it so edges get flipped to the + # forward direction instead of importing a redundant inverse type. + reverse_typeids = set() + controls_newid = None + for t in h.source.rows('shopdb_src', + 'SELECT relationshiptypeid, relationshiptype FROM relationshiptypes WHERE isactive=1'): + name = (t['relationshiptype'] or '').strip() + if not name: + continue + lname = name.lower() + if 'controlled by' in lname: + reverse_typeids.add(t['relationshiptypeid']) + continue # do not create an inverse type; edges map to Controls + newid = _upsert(h, '/api/assets/relationshiptypes', {'relationshiptype': name}, + 'relationshiptype', 'relationshiptypeid', '/api/assets/relationshiptypes') + if newid: + h.ids.put('relationshiptype', t['relationshiptypeid'], newid) + if lname == 'controls': + controls_newid = newid + + seen = set() + made = dropped = 0 + for r in h.source.rows('shopdb_src', + 'SELECT machineid, related_machineid, relationshiptypeid ' + 'FROM machinerelationships WHERE isactive=1'): + source = h.ids.get('asset', r['machineid']) + target = h.ids.get('asset', r['related_machineid']) + # "Controlled By" edges flip to the forward Controls direction. + if r['relationshiptypeid'] in reverse_typeids: + source, target = target, source + typeid = controls_newid + else: + typeid = h.ids.get('relationshiptype', r['relationshiptypeid']) + if not (source and target and typeid) or source == target: + dropped += 1 + continue + key = (source, target, typeid) + if key in seen: + continue + seen.add(key) + status, _ = h.post('/api/assets/relationships', + {'sourceassetid': source, 'targetassetid': target, + 'relationshiptypeid': typeid}) + if status in (200, 201): + made += 1 + return {'relationships': made, 'dropped_unresolved': dropped} + + +def stage_subnets(h): + """Subnets + VLANs. Classic cidr is the suffix only; full CIDR = + INET_NTOA(ipstart)+suffix. VLANs are lookup-or-create by number; duplicate + full CIDRs are first-wins (409 skip).""" + import socket + import struct + subtypes = {s['subnettypeid']: s['subnettype'] for s in + h.source.rows('shopdb_src', 'SELECT subnettypeid, subnettype FROM subnettypes')} + made = 0 + for s in h.source.rows('shopdb_src', + 'SELECT vlan, description, ipstart, cidr, subnettypeid FROM subnets WHERE isactive=1'): + if s['ipstart'] is None or not s['cidr']: + continue + network = socket.inet_ntoa(struct.pack('>I', s['ipstart'] & 0xffffffff)) + suffix = str(s['cidr']).strip() + cidr = network + (suffix if suffix.startswith('/') else '/' + suffix) + vlanid = None + if s['vlan']: + vlanid = _upsert(h, '/api/network/vlans', + {'vlannumber': s['vlan'], 'name': f"VLAN {s['vlan']}"}, + 'vlannumber', 'vlanid', '/api/network/vlans') + name = (s['description'] or '').strip() or cidr + payload = {'name': name, 'cidr': cidr, 'networkaddress': network, + 'vlanid': vlanid, 'subnettype': subtypes.get(s['subnettypeid'])} + status, _ = h.post('/api/network/subnets', payload) + if status in (200, 201): + made += 1 + return {'subnets': made} + + +def stage_usb(h): + """CMMC USB kiosk: devices + the check-in/out log paired into checkouts. + Requires selfhosted mode. cmmc users are directory data, not usb rows.""" + from shopdb.core.models import Setting + from shopdb.extensions import db + Setting.set('usb_directory_mode', 'selfhosted', valuetype='string', category='usb') + db.session.commit() + + devices = 0 + for d in h.source.rows('cmmc_usb_src', 'SELECT device_id, device_desc, device_owner, locker_location FROM devices'): + did = (d['device_id'] or '').strip() + if not did or did.lower() in ('test', 'test123'): + continue + status, _ = h.post('/api/usb', {'device_id': did, 'device_desc': d['device_desc'], + 'device_owner': d['device_owner'], + 'locker_location': d['locker_location']}) + if status in (200, 201, 409): + devices += 1 + + # Replay the log chronologically per device, tracking open state so we only + # check out an available device and check in an out one (the source log has + # unpaired entries - a checkin with no open checkout would 400). + events = 0 + checked_out = {} + log = h.source.rows('cmmc_usb_src', + 'SELECT device_id, badge_number, action, timestamp, locker_location ' + 'FROM checkinoutlog ORDER BY device_id, timestamp') + for e in log: + did = (e['device_id'] or '').strip() + badge = str(e['badge_number'] or '').strip() + action = (e['action'] or '').strip().lower() + if not (did and badge): + continue + when = str(e['timestamp']) if e['timestamp'] else None + if 'out' in action and not checked_out.get(did): + status, _ = h.post(f'/api/usb/{did}/checkout', + {'badge': badge, 'checkouttime': when, + 'locker_location': e['locker_location']}) + if status in (200, 201): + checked_out[did] = True + events += 1 + elif 'in' in action and checked_out.get(did): + status, _ = h.post(f'/api/usb/{did}/checkin', {'badge': badge, 'checkintime': when}) + if status in (200, 201): + checked_out[did] = False + events += 1 + return {'usb_devices': devices, 'usb_events': events} + + +def stage_verify(h): + """Row-count audit: source active count vs target count for each entity.""" + checks = [ + ('vendors', 'shopdb_src', 'SELECT COUNT(*) c FROM vendors WHERE isactive=1', 'vendors'), + ('assets', 'shopdb_src', 'SELECT COUNT(*) c FROM machines WHERE isactive=1', 'assets'), + ('applications', 'shopdb_src', 'SELECT COUNT(*) c FROM applications WHERE isactive=1', 'applications'), + ('knowledgebase', 'shopdb_src', 'SELECT COUNT(*) c FROM knowledgebase WHERE isactive=1', 'knowledgebase'), + ('employees', 'wjf_employees_src', 'SELECT COUNT(*) c FROM employees', 'directoryemployees'), + ] + from shopdb.extensions import db + from sqlalchemy import text + report = {} + for label, sdb, sql, target_table in checks: + src = h.source.rows(sdb, sql)[0]['c'] + tgt = db.session.execute(text(f'SELECT COUNT(*) FROM {target_table}')).scalar() + report[label] = f'source~{src} target={tgt}' + return report + + +STAGES = { + 'reference': stage_reference, + 'employees': stage_employees, + 'catalog': stage_catalog, + 'assets': stage_assets, + 'locations': stage_locations, + 'printers': stage_printers, + 'metrology': stage_metrology, + 'communications': stage_communications, + 'applications': stage_applications, + 'warranties': stage_warranties, + 'notifications': stage_notifications, + 'knowledgebase': stage_knowledgebase, + 'relationships': stage_relationships, + 'subnets': stage_subnets, + 'usb': stage_usb, + 'verify': stage_verify, +} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + '--stages', + default='reference,employees,catalog,assets,locations,printers,' + 'metrology,communications,applications,warranties,notifications,' + 'knowledgebase,relationships,subnets,usb,verify', + help='comma list of stages to run') + args = parser.parse_args() + + h = Harness() + print(f'== WJ import loader (target: {h.app.config["SQLALCHEMY_DATABASE_URI"].split("/")[-1]}) ==') + try: + for name in args.stages.split(','): + name = name.strip() + if name not in STAGES: + print(f' skip unknown stage: {name}') + continue + result = STAGES[name](h) + print(f' [{name}] {result}') + if h.errors: + print(f'\n {len(h.errors)} endpoint error(s):') + for path, code, _payload, data in h.errors[:10]: + print(f' {code} {path}: {data}') + finally: + h.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/test-external-plugin.sh b/scripts/test-external-plugin.sh new file mode 100755 index 00000000..aa61e99d --- /dev/null +++ b/scripts/test-external-plugin.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# test-external-plugin.sh +# +# Verify an out-of-tree shopdb-flask plugin against a pinned framework build. +# Stand up a throwaway copy of the framework, drop the plugin into +# plugins// as a symlink (the mechanism ADR-003 documents for sister +# sites), and run the framework contract tests plus the plugin's own tests. +# Nonzero exit means the plugin is not compatible with that framework ref. +# +# Two modes: +# +# CI / remote (default): clone the framework at FRAMEWORK_REF from +# FRAMEWORK_URL, build a fresh venv, pip install requirements. Needs +# network access to git and PyPI. +# +# Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk. +# The script exports that checkout at HEAD with `git archive` (no network) +# and reuses the checkout's existing venv, so it runs with no internet. +# +# Inputs (env var, or positional): +# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required. +# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main. +# FRAMEWORK_URL framework git URL for CI mode. +# Default: https:///ge-aerospace/shopdb-flask.git +# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline. +# +# Examples: +# # CI: test the plugin in the current repo against a pinned tag +# PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh +# +# # Offline: test a bundled plugin against this checkout, no network +# LOCAL_FRAMEWORK=. PLUGIN_DIR=plugins/warranty scripts/test-external-plugin.sh +# +# Coverage: a symlinked plugin is discovered and loaded by the plugin loader +# (loadability, manifest validity, core_version range) AND scanned by the +# import-surface contract test - the scanner resolves each plugin dir before +# walking, since bare Path.rglob does not descend symlinks. Optionally keep an +# equivalent import-surface assertion in your own tests/ so violations fail +# in your repo's CI too (see the PLUGIN-EXTERNAL-REPO wiki page). + +set -eu + +PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}" +FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}" +FRAMEWORK_URL="${FRAMEWORK_URL:-https:///ge-aerospace/shopdb-flask.git}" +LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}" + +if [ -z "$PLUGIN_DIR" ]; then + echo "ERROR: PLUGIN_DIR is required (env var or first argument)." >&2 + exit 2 +fi +if [ ! -f "$PLUGIN_DIR/manifest.json" ]; then + echo "ERROR: $PLUGIN_DIR has no manifest.json - not a plugin directory." >&2 + exit 2 +fi + +# Absolute path so the symlink still resolves after we cd into the temp tree. +PLUGIN_ABS="$(cd "$PLUGIN_DIR" && pwd)" +# Plugin name: prefer the manifest name, fall back to the directory basename. +PLUGIN_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ABS/manifest.json" | head -n1)" +if [ -z "$PLUGIN_NAME" ]; then + PLUGIN_NAME="$(basename "$PLUGIN_ABS")" +fi + +WORKDIR="$(mktemp -d)" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +FRAMEWORK="$WORKDIR/framework" +mkdir -p "$FRAMEWORK" + +if [ -n "$LOCAL_FRAMEWORK" ]; then + echo "==> Local mode: exporting framework from $LOCAL_FRAMEWORK (HEAD)" + LOCAL_ABS="$(cd "$LOCAL_FRAMEWORK" && pwd)" + git -C "$LOCAL_ABS" archive HEAD | tar -x -C "$FRAMEWORK" + # Reuse the checkout's venv so no pip and no network are needed. + if [ -x "$LOCAL_ABS/venv/bin/python" ]; then + PYTHON="$LOCAL_ABS/venv/bin/python" + elif [ -x "$LOCAL_ABS/.venv/bin/python" ]; then + PYTHON="$LOCAL_ABS/.venv/bin/python" + else + echo "ERROR: no venv found under $LOCAL_ABS (looked for venv/ and .venv/)." >&2 + exit 2 + fi +else + echo "==> CI mode: cloning $FRAMEWORK_URL @ $FRAMEWORK_REF" + git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" "$FRAMEWORK" + echo "==> Creating venv and installing requirements" + python3 -m venv "$WORKDIR/venv" + PYTHON="$WORKDIR/venv/bin/python" + "$PYTHON" -m pip install --upgrade pip >/dev/null + "$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt" +fi + +echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/" +rm -rf "$FRAMEWORK/plugins/$PLUGIN_NAME" +ln -s "$PLUGIN_ABS" "$FRAMEWORK/plugins/$PLUGIN_NAME" + +# Drop any exported registry so the loader re-discovers and enables the plugin +# (the test conftest seeds a fresh registry from plugins/*/manifest.json). +rm -f "$FRAMEWORK/instance/plugins.json" + +RC=0 + +echo "==> Running framework contract tests" +( cd "$FRAMEWORK" && "$PYTHON" -m pytest tests/test_plugin_contract.py -q ) || RC=1 + +if [ -d "$PLUGIN_ABS/tests" ]; then + echo "==> Running plugin's own tests" + ( cd "$FRAMEWORK" && "$PYTHON" -m pytest "$PLUGIN_ABS/tests" -q ) || RC=1 +else + echo "==> Plugin has no tests/ directory - skipping plugin test step" +fi + +if [ "$RC" -eq 0 ]; then + echo "==> PASS: plugin '$PLUGIN_NAME' is compatible with framework ref '$FRAMEWORK_REF'" +else + echo "==> FAIL: plugin '$PLUGIN_NAME' - see output above" >&2 +fi +exit "$RC" diff --git a/shopdb/__init__.py b/shopdb/__init__.py new file mode 100644 index 00000000..2a6857a2 --- /dev/null +++ b/shopdb/__init__.py @@ -0,0 +1,260 @@ +"""Flask application factory.""" + +import os +import logging +from flask import Flask, send_from_directory + +from .config import config +from .extensions import db, jwt, init_extensions +from .plugins import plugin_manager + +# Platform contract version. See ADR-001 for the contract surface and +# ADR-002 for the bump rules. Plugins declare a compatible range in +# their manifest.json `core_version` field. Pre-1.0 (0.x) means the +# contract is still settling; sister sites should pin tight ranges. +# 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache, +# model bases, core models, response + pagination helpers, employee_connection) +# so plugins no longer import internal core paths. Additive, hence minor bump. +# 0.4.0: removed the never-implemented get_searchable_fields hook (search is a +# core concern over the asset model) and wired the get_dashboard_widgets hook to +# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction. +# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge +# plugin report cards into the Reports hub. Additive optional hook, minor bump. +# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards, +# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the +# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump. +# 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api +# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the +# machines plugin list/detail to collapse dual-bay pairs. Two additive names, +# minor bump. +# 0.10.0: added the get_permissions hook so a plugin declares the RBAC +# permissions its own routes enforce, instead of core accumulating them in one +# catalog. Consumed by full_permission_catalog() (core + enabled plugins), +# which backs seeding, the role grid, and API-token scope validation. Additive +# optional hook, minor bump. +# 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's +# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped +# managed service token without importing core token internals. Additive name +# on the import surface, minor bump. +__contract_version__ = '0.11.0' + +# Product release version (see ADR-007). The product version and the +# plugin-contract version above are distinct series with independent +# bump rules. Not part of the shopdb.api contract surface, so it is +# not re-exported there. +__version__ = '0.7.0' + + +def create_app(config_name: str = None) -> Flask: + """ + Application factory. + + Args: + config_name: Configuration name ('development', 'production', 'testing') + + Returns: + Configured Flask application + """ + if config_name is None: + config_name = os.environ.get('FLASK_ENV', 'development') + + app = Flask(__name__, instance_relative_config=True) + + config_class = config.get(config_name, config['default']) + + # Production must validate its env-driven config before boot. + if config_name == 'production' and hasattr(config_class, 'validate'): + config_class.validate() + + app.config.from_object(config_class) + + # Load instance config if exists + app.config.from_pyfile('config.py', silent=True) + + # Per-plugin collector keys (ADR-006) are dynamic env-vars + # (COLLECTOR_API_KEY_) that from_object cannot pick up because + # they are not class attributes. Copy them in explicitly so per-plugin + # credential isolation works in real deploys, not just tests. + for envname, envvalue in os.environ.items(): + if envname.startswith('COLLECTOR_API_KEY_') and envvalue: + app.config[envname] = envvalue + + # Ensure instance folder exists + os.makedirs(app.instance_path, exist_ok=True) + + # Configure logging + configure_logging(app) + + # Initialize extensions + init_extensions(app) + + # Initialize plugin manager + with app.app_context(): + plugin_manager.init_app(app, db) + + # Register core blueprints + register_blueprints(app) + + # Personal API token auth shim: recognize `Bearer shopdb_pat_...` before + # any JWT decode and mint a request-scoped JWT for the token's owner. + from .utils.apitoken_auth import install_apitoken_auth + install_apitoken_auth(app) + + # Register CLI commands + register_cli_commands(app) + + # Register error handlers + register_error_handlers(app) + + # Serve Vue frontend + register_frontend_routes(app) + + # JWT user loader (identity is a string in JWT, convert to int for DB lookup) + @jwt.user_lookup_loader + def user_lookup_callback(_jwt_header, jwt_data): + from .core.models import User + identity = jwt_data["sub"] + return db.session.get(User, int(identity)) + + return app + + +CORE_BLUEPRINT_NAMES = ( + 'auth', + 'assets', + 'modeltypes', + 'plugins', + 'vendors', + 'models', + 'businessunits', + 'locations', + 'operatingsystems', + 'dashboard', + 'dashboarddefaults', + 'applications', + 'supportteams', + 'search', + 'reports', + 'collector', + 'settings', + 'auditlogs', + 'users', + 'customfields', + 'setup', + 'pluginui', + 'apitokens', +) + + +def register_blueprints(app: Flask): + """Register core API blueprints from CORE_BLUEPRINT_NAMES. + + Each entry maps to an attribute `_bp` exported by + `shopdb.core.api` and a URL prefix `/api/`. Adding a new + core resource is one entry in CORE_BLUEPRINT_NAMES, not a 3-line + edit in this function. + """ + from .core import api as api_module + + api_prefix = '/api' + for name in CORE_BLUEPRINT_NAMES: + attr_name = f'{name}_bp' + if not hasattr(api_module, attr_name): + raise RuntimeError( + f'Core blueprint "{attr_name}" missing from shopdb.core.api. ' + f'Either add it or remove "{name}" from CORE_BLUEPRINT_NAMES.' + ) + bp = getattr(api_module, attr_name) + app.register_blueprint(bp, url_prefix=f'{api_prefix}/{name}') + + +def register_cli_commands(app: Flask): + """Register Flask CLI commands.""" + from .plugins.cli import plugin_cli + from .cli import db_cli, seed_cli, relationships_cli + + app.cli.add_command(plugin_cli) + app.cli.add_command(db_cli) + app.cli.add_command(seed_cli) + app.cli.add_command(relationships_cli) + + +def register_error_handlers(app: Flask): + """Register error handlers.""" + from .utils.responses import error_response, ErrorCodes + from .exceptions import ShopDBException + + @app.errorhandler(ShopDBException) + def handle_shopdb_exception(error): + http_codes = { + 'NOT_FOUND': 404, + 'UNAUTHORIZED': 401, + 'FORBIDDEN': 403, + 'CONFLICT': 409, + 'VALIDATION_ERROR': 400, + } + http_code = http_codes.get(error.code, 400) + return error_response( + error.code, + error.message, + details=error.details, + http_code=http_code + ) + + @app.errorhandler(404) + def not_found_error(error): + return error_response( + ErrorCodes.NOT_FOUND, + 'Resource not found', + http_code=404 + ) + + @app.errorhandler(500) + def internal_error(error): + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'An internal error occurred', + http_code=500 + ) + + @app.errorhandler(401) + def unauthorized_error(error): + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Authentication required', + http_code=401 + ) + + +def register_frontend_routes(app: Flask): + """Serve Vue frontend static files.""" + frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist') + + @app.route('/', defaults={'path': ''}) + @app.route('/') + def serve_frontend(path): + # Don't serve API routes as frontend + if path.startswith('api/'): + from .utils.responses import error_response, ErrorCodes + return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404) + + # Try to serve a static asset directly. send_from_directory handles + # the safe-join + 404 itself; no explicit existence probe needed + # (the probe was a path-traversal risk surface). + if path: + try: + return send_from_directory(frontend_dist, path) + except Exception: + pass + + return send_from_directory(frontend_dist, 'index.html') + + +def configure_logging(app: Flask): + """Configure application logging.""" + log_level = app.config.get('LOG_LEVEL', 'INFO') + + logging.basicConfig( + level=getattr(logging, log_level), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py new file mode 100644 index 00000000..d4b2ac95 --- /dev/null +++ b/shopdb/api/__init__.py @@ -0,0 +1,271 @@ +"""Public API namespace exposed to plugins. + +Plugin authors at sister sites import from this module. The contract is +locked in ADR-001 and versioned per ADR-002. Helpers added here become +part of the platform contract; bumps follow ADR-002 rules. + +Currently exposed: +- audit_log: record an audit log entry with consistent schema +- resolve_asset_position: compute an asset's resolved map position + +Setting helpers are exposed via BasePlugin instance methods +(plugin.get_setting, plugin.set_setting), not from this namespace. +""" + +from typing import Any, Dict, Optional + +# -- Plugin contract surface (ADR-001, versioned per ADR-002) ---------------- +# Everything a plugin is allowed to import from the core lives here. Plugins +# import these from `shopdb.api`, never from internal paths like +# `shopdb.core.models.*` or `shopdb.extensions`. The contract test +# (tests/test_plugin_contract.py) enforces this. Adding a name here is an +# additive (minor) contract change; removing one is breaking (major). + +# Infrastructure +from shopdb.extensions import db, cache + +# Model base classes for declaring plugin tables +from shopdb.core.models.base import BaseModel, AuditMixin + +# Core domain models plugins legitimately reference (the asset contract) +from shopdb.core.models import ( + Asset, + AssetType, + AssetStatus, + Vendor, + Model, + Communication, + CommunicationType, + Location, + Setting, + AuditLog, + Application, + AppVersion, + OperatingSystem, + AssetRelationship, + RelationshipType, +) + +# Response + pagination helpers for plugin API blueprints +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes, +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +# Authorization decorators for gating plugin write routes +from shopdb.utils.authz import require_permission, require_role + +# Service-token authorization for unattended plugin endpoints (collector, +# GE-Enforce fetch, ...): checks a scoped managed token without exposing token +# internals. +from shopdb.utils.apitoken_auth import service_token_authorized + +# Import-mode helpers: preserve legacy timestamps during a bulk data import +from shopdb.utils.import_mode import ( + apply_import_timestamps, + import_mode_active, + parse_import_datetime, +) + +# Dualpath single-machine collapse (a dual-bay pair is one physical machine) +from shopdb.core.services.dualpath import ( + resolve_dualpath_pairs, + dualpath_single_machine_enabled, +) + +# Legacy employee directory lookup (read-only) used by notifications +from shopdb.utils.employee_db import employee_connection + +# CMMC USB check-in/out database (read-write) used by the usb plugin +from shopdb.utils.cmmc_usb_db import cmmc_usb_connection + + +def audit_log( + action: str, + entitytype: str, + entityid: int = None, + entityname: str = None, + changes: Dict = None, + details: Dict = None, +) -> AuditLog: + """Record an audit log entry with the framework's standard schema. + + Plugins call this to record state changes on their own assets in a + way that is consistent with core auditing. The function delegates to + AuditLog.log() which already captures the current user, IP address, + and user agent from the Flask request context. + + Args: + action: Action verb in past tense ('created', 'updated', 'deleted') + entitytype: Class name of the entity affected ('Computer', 'Printer') + entityid: Primary key of the entity + entityname: Human-readable identifier (hostname, asset number) + changes: Dict with 'before' and 'after' snapshots for updates + details: Arbitrary additional context + + Returns: + The created AuditLog instance, already committed to the DB. + """ + return AuditLog.log( + action=action, + entitytype=entitytype, + entityid=entityid, + entityname=entityname, + changes=changes, + details=details, + ) + + +# Cap how deep the relationship-walk traverses before giving up. ADR-001 +# specifies a max walk depth of 3 to bound the work per request, with a +# visited-set guarding against cycles. Past this depth the walk treats the +# next hop as if it had no position to contribute. +_POSITION_WALK_MAX_DEPTH = 3 + +# Relationship type names whose edges are eligible for the inheritance walk +# when inheritsposition is true on the edge. Ordered by priority per +# ADR-001 ("partof first, then controls"). Edges of other types are never +# followed even if inheritsposition is true. +_INHERITABLE_TYPES = ('partof', 'controls') + + +def _walk_related_for_position(asset, visited, depth): + """Recursive helper for resolve_asset_position relationship walk. Returns + a (mapx, mapy) tuple from the first related asset whose position + resolves, or None. Visited tracks assetids already explored to break + cycles.""" + if depth >= _POSITION_WALK_MAX_DEPTH: + return None + aid = getattr(asset, 'assetid', None) + if aid is None or aid in visited: + return None + visited.add(aid) + + edges = [] + for rel in list(getattr(asset, 'outgoing_relationships', []) or []): + edges.append((rel, getattr(rel, 'targetasset', None))) + for rel in list(getattr(asset, 'incoming_relationships', []) or []): + edges.append((rel, getattr(rel, 'sourceasset', None))) + + def _priority(edge): + rel = edge[0] + rtype = getattr(rel, 'relationshiptype', None) + type_name = getattr(rtype, 'relationshiptype', '') if rtype else '' + try: + return _INHERITABLE_TYPES.index(type_name) + except ValueError: + return len(_INHERITABLE_TYPES) + + edges.sort(key=_priority) + + for rel, neighbor in edges: + if neighbor is None: + continue + if not getattr(rel, 'inheritsposition', False): + continue + if not getattr(rel, 'isactive', True): + continue + rtype = getattr(rel, 'relationshiptype', None) + type_name = getattr(rtype, 'relationshiptype', '') if rtype else '' + if type_name not in _INHERITABLE_TYPES: + continue + + n_mapx = getattr(neighbor, 'mapx', None) + n_mapy = getattr(neighbor, 'mapy', None) + if n_mapx is not None and n_mapy is not None: + return (n_mapx, n_mapy) + + recursed = _walk_related_for_position(neighbor, visited, depth + 1) + if recursed is not None: + return recursed + return None + + +def resolve_asset_position(asset) -> Optional[Dict[str, Any]]: + """Compute the resolved map position for an asset. + + Per ADR-001, position resolution follows this priority chain: + 1. Asset-specific override (asset.mapx, asset.mapy) + 2. Walk relationships where inheritsposition is true on edges of type + partof or controls (partof first), depth-limited and cycle-safe + 3. Asset's location coords (asset.location.mapx, .mapy) + 4. None (asset is unplaced, rendered in a tray) + + Returns a dict {'mapx', 'mapy', 'positionsource'} where positionsource + is one of 'self', 'related', 'location'. Returns None when no priority + yields coordinates. + """ + mapx = getattr(asset, 'mapx', None) + mapy = getattr(asset, 'mapy', None) + if mapx is not None and mapy is not None: + return {'mapx': mapx, 'mapy': mapy, 'positionsource': 'self'} + + related = _walk_related_for_position(asset, set(), 0) + if related is not None: + return {'mapx': related[0], 'mapy': related[1], 'positionsource': 'related'} + + location = getattr(asset, 'location', None) + if location is not None: + loc_mapx = getattr(location, 'mapx', None) + loc_mapy = getattr(location, 'mapy', None) + if loc_mapx is not None and loc_mapy is not None: + return { + 'mapx': loc_mapx, + 'mapy': loc_mapy, + 'positionsource': 'location', + } + + return None + + +__all__ = [ + # Helpers + 'audit_log', + 'resolve_asset_position', + 'resolve_dualpath_pairs', + 'dualpath_single_machine_enabled', + # Infrastructure + 'db', + 'cache', + # Model bases + 'BaseModel', + 'AuditMixin', + # Core models + 'Asset', + 'AssetType', + 'AssetStatus', + 'Vendor', + 'Model', + 'Communication', + 'CommunicationType', + 'Location', + 'Setting', + 'AuditLog', + 'Application', + 'AppVersion', + 'OperatingSystem', + 'AssetRelationship', + 'RelationshipType', + # Response + pagination helpers + 'success_response', + 'error_response', + 'paginated_response', + 'ErrorCodes', + 'get_pagination_params', + 'paginate_query', + # Authorization decorators + 'require_permission', + 'require_role', + 'service_token_authorized', + # Import-mode helpers + 'apply_import_timestamps', + 'import_mode_active', + 'parse_import_datetime', + # Legacy employee directory + 'employee_connection', + # CMMC USB check-in/out database + 'cmmc_usb_connection', +] diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py new file mode 100644 index 00000000..3d991339 --- /dev/null +++ b/shopdb/cli/__init__.py @@ -0,0 +1,513 @@ +"""Flask CLI commands.""" + +import click +from flask.cli import with_appcontext + + +@click.group('db-utils') +def db_cli(): + """Database utility commands.""" + pass + + +@db_cli.command('create-all') +@with_appcontext +def create_all(): + """Create all database tables.""" + from shopdb.extensions import db + + db.create_all() + click.echo(click.style("All tables created.", fg='green')) + + +@db_cli.command('preflight') +@with_appcontext +def preflight(): + """Check install prerequisites before `flask db upgrade`. + + Reports what must be fixed/installed (Python, required env, DB connectivity, + and the MySQL 5.6 utf8mb4 index flags). Exits non-zero if anything blocks. + """ + import sys + import os + from sqlalchemy import text + from flask import current_app + from shopdb.extensions import db + + failures = [] + warnings = [] + + def ok(msg): + click.echo(click.style(' PASS ', fg='green') + msg) + + def fail(msg, fix): + failures.append((msg, fix)) + click.echo(click.style(' FAIL ', fg='red') + msg) + click.echo(click.style(' fix: ', fg='red') + fix) + + def warn(msg, fix): + warnings.append((msg, fix)) + click.echo(click.style(' WARN ', fg='yellow') + msg) + click.echo(click.style(' ', fg='yellow') + fix) + + click.echo(click.style('ShopDB preflight', bold=True)) + + # Python + py = sys.version_info + if py >= (3, 9): + ok(f'Python {py.major}.{py.minor}.{py.micro}') + else: + fail(f'Python {py.major}.{py.minor} is too old', + 'Install Python 3.9 or newer.') + + # Required config + for key in ('SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL'): + value = current_app.config.get(key) or os.environ.get(key) + if value and 'change' not in str(value).lower() and 'dev-' not in str(value).lower(): + ok(f'{key} is set') + else: + fail(f'{key} is missing or a dev default', + f'Set {key} in .env (64+ random chars for the secrets).') + + # DB connectivity + MySQL index prerequisites + try: + version = db.session.execute(text('SELECT VERSION()')).scalar() + ok(f'Database reachable (MySQL {version})') + except Exception as exc: + fail('Cannot connect to the database', f'Check DATABASE_URL / server. ({exc})') + version = '' + + if version and version.startswith('5.6'): + variables = {} + for name in ('innodb_large_prefix', 'innodb_file_format', 'innodb_file_per_table'): + try: + row = db.session.execute(text(f"SHOW VARIABLES LIKE '{name}'")).fetchone() + variables[name] = row[1] if row else None + except Exception: + variables[name] = None + needed = {'innodb_large_prefix': 'ON', 'innodb_file_format': 'Barracuda', + 'innodb_file_per_table': 'ON'} + bad = [n for n, want in needed.items() if str(variables.get(n)).lower() != want.lower()] + if bad: + fail('MySQL 5.6 index flags not set: ' + ', '.join(bad), + 'Add to my.cnf [mysqld]: innodb_file_per_table=1, ' + 'innodb_file_format=Barracuda, innodb_large_prefix=1 (then restart). ' + 'Otherwise `flask db upgrade` fails with error 1071.') + else: + ok('MySQL 5.6 index flags OK (Barracuda + large_prefix)') + elif version: + ok('MySQL 5.7+/8.0 - no extra index flags needed') + + click.echo('') + if failures: + click.echo(click.style(f'{len(failures)} blocker(s). Fix them before installing.', fg='red', bold=True)) + raise SystemExit(1) + click.echo(click.style('All prerequisites met.' + (f' ({len(warnings)} warning(s))' if warnings else ''), fg='green', bold=True)) + + +@db_cli.command('drop-all') +@click.confirmation_option(prompt='This will delete ALL data. Are you sure?') +@with_appcontext +def drop_all(): + """Drop all database tables.""" + from shopdb.extensions import db + + db.drop_all() + click.echo(click.style("All tables dropped.", fg='yellow')) + + +@click.group('seed') +def seed_cli(): + """Database seeding commands.""" + pass + + +@seed_cli.command('reference-data') +@with_appcontext +def seed_reference_data(): + """Seed reference data (model types, statuses, etc.).""" + from shopdb.extensions import db + from shopdb.core.models import (ModelType, OperatingSystem, AssetStatus, + LocationType, CommunicationType) + from shopdb.core.models.relationship import RelationshipType + + # Model types (type the vendor models catalog) + model_types = [ + {'modeltype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'}, + {'modeltype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'}, + {'modeltype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'}, + {'modeltype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'}, + {'modeltype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'}, + {'modeltype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'}, + {'modeltype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'}, + {'modeltype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'}, + {'modeltype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'}, + {'modeltype': 'Server', 'category': 'Network', 'description': 'Server'}, + {'modeltype': 'Switch', 'category': 'Network', 'description': 'Network Switch'}, + {'modeltype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'}, + ] + + for mt_data in model_types: + existing = ModelType.query.filter_by(modeltype=mt_data['modeltype']).first() + if not existing: + mt = ModelType(**mt_data) + db.session.add(mt) + + # Asset statuses (canonical set - the asset model is the contract) + asset_statuses = [ + {'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'}, + {'status': 'Inventory', 'description': 'In inventory', 'color': '#17a2b8'}, + {'status': 'In Repair', 'description': 'Being repaired', 'color': '#ffc107'}, + {'status': 'Retired', 'description': 'No longer in use', 'color': '#6c757d'}, + {'status': 'Returned', 'description': 'Returned to vendor or owner', 'color': '#fd7e14'}, + {'status': 'Warrantied', 'description': 'Under warranty service', 'color': '#20c997'}, + {'status': 'Lost', 'description': 'Lost or missing', 'color': '#dc3545'}, + ] + + for s_data in asset_statuses: + existing = AssetStatus.query.filter_by(status=s_data['status']).first() + if not existing: + db.session.add(AssetStatus(isactive=True, **s_data)) + elif existing.isactive is not True: + existing.isactive = True + + # Location types (ADR-001) + location_types = ['section', 'cell', 'subcell', 'operation', 'meetingroom', + 'lab', 'office', 'storage', 'hallway', 'networkcloset', + 'building'] + for lt in location_types: + if not LocationType.query.filter_by(locationtype=lt).first(): + db.session.add(LocationType(locationtype=lt, isactive=True)) + + # Communication types (how an asset is reached / its interfaces). The IP + # type is what the printer + network create routes attach an ipaddress to, + # so it must exist before any asset import. + comm_types = [ + ('IP', 'IP address / network reachable'), + ('Serial', 'Serial (RS-232) connection'), + ('Network_Interface', 'Physical network interface (MAC/port)'), + ('USB', 'USB connection'), + ('Parallel', 'Parallel port connection'), + ('VNC', 'VNC remote access'), + ('FTP', 'FTP file transfer'), + ('DNC', 'Direct numerical control link'), + ] + for comtype, description in comm_types: + if not CommunicationType.query.filter_by(comtype=comtype).first(): + db.session.add(CommunicationType(comtype=comtype, description=description)) + + # Operating systems + os_list = [ + {'osname': 'Windows 10', 'osversion': '10.0'}, + {'osname': 'Windows 11', 'osversion': '11.0'}, + {'osname': 'Windows Server 2019', 'osversion': '2019'}, + {'osname': 'Windows Server 2022', 'osversion': '2022'}, + {'osname': 'Linux', 'osversion': 'Various'}, + ] + + for os_data in os_list: + existing = OperatingSystem.query.filter_by(osname=os_data['osname']).first() + if not existing: + os_obj = OperatingSystem(**os_data) + db.session.add(os_obj) + + # Connection types (pre-1.0 legacy; kept for backward compat with + # existing relationship rows. New ADR-001 code reasons about the three + # canonical types below via free-text label.) + # all symmetric physical/network links -> isdirectional=False so the + # relationships card shows one direction-blind "connected" entry per peer. + connection_types = [ + {'relationshiptype': 'Serial Cable', 'description': 'RS-232 or similar serial connection', 'isdirectional': False}, + {'relationshiptype': 'Direct Ethernet', 'description': 'Direct network cable (airgapped)', 'isdirectional': False}, + {'relationshiptype': 'USB', 'description': 'USB connection', 'isdirectional': False}, + {'relationshiptype': 'WiFi', 'description': 'Wireless network connection', 'isdirectional': False}, + {'relationshiptype': 'Dualpath', 'description': 'Redundant/failover network path', 'isdirectional': False}, + ] + + for ct_data in connection_types: + existing = RelationshipType.query.filter_by(relationshiptype=ct_data['relationshiptype']).first() + if not existing: + ct = RelationshipType(**ct_data) + db.session.add(ct) + + # ADR-001 canonical relationship types. Created first, then their + # propagation rails are seeded as relationshiptypepropagations rows (M:N). + # All idempotent. + # + # MySQL collation is case-insensitive by default, which would let a + # legacy capitalized row (e.g. "Controls") match the lowercase + # "controls" check and skip the insert. Force binary comparison via + # collate so the three ADR-001 types stay distinct from any legacy + # rows with the same spelling but different case. + from sqlalchemy import func, literal + def _lookup_binary(name): + dialect = db.engine.dialect.name + if dialect == 'mysql': + return RelationshipType.query.filter( + func.binary(RelationshipType.relationshiptype) == literal(name) + ).first() + return RelationshipType.query.filter_by(relationshiptype=name).first() + + adr_types = [ + {'relationshiptype': 'partof', 'description': 'Composition / sub-assembly (ADR-001)', 'isdirectional': True}, + {'relationshiptype': 'controls', 'description': 'Operational authority over another asset (ADR-001)', 'isdirectional': True}, + {'relationshiptype': 'connectedto', 'description': 'Network or data link without authority (ADR-001)', 'isdirectional': False}, + ] + for at in adr_types: + if not _lookup_binary(at['relationshiptype']): + db.session.add(RelationshipType(**at)) + db.session.flush() + + # Seed `controls` propagation rails as M:N rows. controls -> partof + # (declared; directional rail, not consumed yet) and controls -> Dualpath + # (consumed; a dual-bay pair shares one controller so both bays carry + # controls). Idempotent, resolved by name, skipped if a type is missing. + from shopdb.core.models.relationship import RelationshipTypePropagation + + def _seed_propagation(sourcename, throughname): + source = _lookup_binary(sourcename) + through = _lookup_binary(throughname) + if not source or not through: + return + exists = RelationshipTypePropagation.query.filter_by( + relationshiptypeid=source.relationshiptypeid, + throughtypeid=through.relationshiptypeid, + ).first() + if not exists: + db.session.add(RelationshipTypePropagation( + relationshiptypeid=source.relationshiptypeid, + throughtypeid=through.relationshiptypeid, + )) + + _seed_propagation('controls', 'partof') + _seed_propagation('controls', 'Dualpath') + + # Default-printer link: a PC asset -> its default printer asset. Read by the + # printer-installer endpoint (parity with classic apipcdefaultprinter.asp). + # Attribute-style edge, not a position rail, so no propagation. + if not _lookup_binary('defaultprinter'): + db.session.add(RelationshipType( + relationshiptype='defaultprinter', + description='PC to its default printer (installer preselect, ADR-001)' + )) + + db.session.commit() + click.echo(click.style("Reference data seeded.", fg='green')) + + +@seed_cli.command('test-user') +@with_appcontext +def seed_test_user(): + """Create a test admin user (admin / admin123). DEV ONLY.""" + from flask import current_app + from shopdb.extensions import db + from shopdb.core.models import User, Role + from werkzeug.security import generate_password_hash + + # Refuse in production: this seeds a well-known credential. Sites bootstrap + # a real admin with `flask seed admin` (generated password) or the wizard. + if not (current_app.config.get('DEBUG') or current_app.config.get('TESTING')): + raise click.ClickException( + 'seed test-user is dev-only (creates admin/admin123). ' + 'Use `flask seed admin` to create a production admin.') + + # Create admin role if not exists + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: + admin_role = Role(rolename='admin', description='Administrator') + db.session.add(admin_role) + + # Create test user + test_user = User.query.filter_by(username='admin').first() + if not test_user: + test_user = User( + username='admin', + email='admin@localhost', + passwordhash=generate_password_hash('admin123'), + isactive=True + ) + test_user.roles.append(admin_role) + db.session.add(test_user) + db.session.commit() + click.echo(click.style("Test user created: admin / admin123", fg='green')) + else: + click.echo(click.style("Test user already exists", fg='yellow')) + + +@seed_cli.command('admin') +@click.option('--username', required=True, help='Admin login username') +@click.option('--email', required=True, help='Admin email address') +@click.option('--password', default=None, + help='Admin password. Omit to generate a strong one and print it once.') +@with_appcontext +def seed_admin(username, email, password): + """Create the first admin user for a new site. + + Password is generated and printed ONCE if not supplied. Store it safely. + """ + import secrets + from shopdb.extensions import db + from shopdb.core.models import User, Role + from werkzeug.security import generate_password_hash + + if User.query.filter_by(username=username).first(): + click.echo(click.style(f'User "{username}" already exists.', fg='yellow')) + return + + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: + admin_role = Role(rolename='admin', description='Administrator') + db.session.add(admin_role) + + generated = password is None + if generated: + password = secrets.token_urlsafe(12) + + user = User(username=username, email=email, + passwordhash=generate_password_hash(password), isactive=True) + user.roles.append(admin_role) + db.session.add(user) + db.session.commit() + + click.echo(click.style(f'Admin "{username}" created.', fg='green')) + if generated: + click.echo(click.style('=' * 52, fg='cyan')) + click.echo(click.style(f' Password: {password}', fg='cyan', bold=True)) + click.echo(click.style(' Store this now - it will not be shown again.', fg='cyan')) + click.echo(click.style('=' * 52, fg='cyan')) + + +@click.group('relationships') +def relationships_cli(): + """Asset-relationship maintenance commands.""" + pass + + +@relationships_cli.command('propagate') +@with_appcontext +def propagate_relationships(): + """Backfill propagated relationship rows across symmetric rails. + + Scans every existing relationship of a type that propagates through a + symmetric through-type (e.g. controls through Dualpath) and creates the + missing fanned-out rows. Idempotent. Also serves the legacy-import flow: + the import creates controls links on primary bays, this fans them out to + the Dualpath partner bays. + """ + from shopdb.extensions import db + from shopdb.core.models.relationship import RelationshipType, AssetRelationship + from shopdb.core.api.assets import propagate_relationship + + # types that actually propagate through at least one symmetric through-type + propagating_ids = [ + t.relationshiptypeid for t in RelationshipType.query.all() + if any(not through.isdirectional for through in t.propagatesthrough) + ] + + total = 0 + if propagating_ids: + rels = AssetRelationship.query.filter( + AssetRelationship.relationshiptypeid.in_(propagating_ids), + AssetRelationship.isactive == True, + ).all() + for rel in rels: + total += len(propagate_relationship(rel)) + db.session.commit() + + click.echo(click.style(f"Propagated {total} relationship row(s).", fg='green')) + + +@relationships_cli.command('fix-controls-direction') +@with_appcontext +def fix_controls_direction(): + """Flip reversed legacy controls rows to PC -> machine. + + Legacy import stores controls as machine -> PC, which reads as the machine + having authority over the PC. In reality the PC is the controller (it + sends programs to the machine and receives logs), so per ADR-001 the PC + must be the source. Flips every active controls row whose source is a + machine asset and target is a computer asset. If the flipped row already + exists, the reversed duplicate is deactivated instead. Idempotent. + """ + from sqlalchemy.orm import aliased + from shopdb.extensions import db + from shopdb.core.models import Asset, AssetType + from shopdb.core.models.relationship import RelationshipType, AssetRelationship + + controls = RelationshipType.query.filter_by(relationshiptype='controls').first() + if not controls: + click.echo(click.style("No 'controls' relationship type; nothing to do.", fg='yellow')) + return + + sourceasset = aliased(Asset) + targetasset = aliased(Asset) + sourcetype = aliased(AssetType) + targettype = aliased(AssetType) + + reversed_rows = ( + AssetRelationship.query + .join(sourceasset, AssetRelationship.sourceassetid == sourceasset.assetid) + .join(targetasset, AssetRelationship.targetassetid == targetasset.assetid) + .join(sourcetype, sourceasset.assettypeid == sourcetype.assettypeid) + .join(targettype, targetasset.assettypeid == targettype.assettypeid) + .filter( + AssetRelationship.relationshiptypeid == controls.relationshiptypeid, + AssetRelationship.isactive == True, + sourcetype.assettype == 'machine', + targettype.assettype == 'computer', + ) + .all() + ) + + flipped = 0 + deactivated = 0 + for row in reversed_rows: + duplicate = AssetRelationship.query.filter_by( + sourceassetid=row.targetassetid, + targetassetid=row.sourceassetid, + relationshiptypeid=controls.relationshiptypeid, + ).first() + if duplicate: + row.isactive = False # flipped row already exists, retire this one + deactivated += 1 + else: + row.sourceassetid, row.targetassetid = row.targetassetid, row.sourceassetid + flipped += 1 + db.session.commit() + + click.echo(click.style( + f"Flipped {flipped} controls row(s) to PC -> machine" + f" ({deactivated} reversed duplicate(s) deactivated).", fg='green')) + + +@seed_cli.command('permissions') +@with_appcontext +def seed_permissions(): + """Seed predefined permissions.""" + from shopdb.extensions import db + from shopdb.core.models import Permission + + created = Permission.seed() + db.session.commit() + click.echo(click.style(f"{created} permissions created.", fg='green')) + + +@seed_cli.command('settings') +@with_appcontext +def seed_settings(): + """Seed default system settings.""" + from shopdb.extensions import db + from shopdb.core.models import Setting + from shopdb.core.api.settings import build_default_settings + + defaults = build_default_settings() + + created = 0 + for d in defaults: + if not Setting.query.filter_by(key=d['key']).first(): + setting = Setting(**d) + db.session.add(setting) + created += 1 + + db.session.commit() + click.echo(click.style(f"{created} default settings created.", fg='green')) diff --git a/shopdb/config.py b/shopdb/config.py new file mode 100644 index 00000000..00bdc042 --- /dev/null +++ b/shopdb/config.py @@ -0,0 +1,186 @@ +"""Flask application configuration.""" + +import os +from datetime import timedelta + +from sqlalchemy.pool import StaticPool + + +class ConfigError(Exception): + """Raised when required configuration is missing or unsafe.""" + + +def _required_env(varname): + """Read an env var; raise ConfigError if missing or empty.""" + value = os.environ.get(varname) + if not value: + raise ConfigError( + f'{varname} is required in production. Set it in the environment ' + f'before starting the app. Insecure defaults are not permitted in ' + f'ProductionConfig.' + ) + return value + + +class Config: + """Base configuration.""" + + SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') + + SQLALCHEMY_DATABASE_URI = os.environ.get( + 'DATABASE_URL', + 'mysql+pymysql://root:password@localhost:3306/shopdb_flask', + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_pre_ping': True, + 'pool_recycle': 300, + # Pin the client connection to utf8mb4 so reads/writes match the + # utf8mb4 schema regardless of the server default charset. TestingConfig + # (SQLite) replaces this whole dict, so the charset arg never reaches it. + 'connect_args': {'charset': 'utf8mb4'}, + } + + JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production') + JWT_ACCESS_TOKEN_EXPIRES = timedelta( + seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 3600)) + ) + JWT_REFRESH_TOKEN_EXPIRES = timedelta( + seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000)) + ) + + CORS_ORIGINS = [ + origin.strip() + for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',') + if origin.strip() + ] + + LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') + + # API key for the unattended PowerShell collector scripts + COLLECTOR_API_KEY = os.environ.get('COLLECTOR_API_KEY', '') + + ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true' + ZABBIX_URL = os.environ.get('ZABBIX_URL', '') + ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '') + + # Read-only HR/employee directory database (separate from the app DB). + # Credentials come from the environment; never hardcode them in source. + # No safe default for the password: unset means empty, the connection + # fails loud rather than silently trying a guessed credential. + EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost') + EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', '') + EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', '') + EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees') + + # Read-write CMMC USB check-in/out database (separate from the app DB). + # Credentials come from the environment; never hardcode them in source. + # No safe default for the password: unset means empty, the connection + # fails loud rather than silently trying a guessed credential. + CMMC_USB_DB_HOST = os.environ.get('CMMC_USB_DB_HOST', 'localhost') + CMMC_USB_DB_USER = os.environ.get('CMMC_USB_DB_USER', '') + CMMC_USB_DB_PASSWORD = os.environ.get('CMMC_USB_DB_PASSWORD', '') + CMMC_USB_DB_NAME = os.environ.get('CMMC_USB_DB_NAME', 'cmmc_usb') + + CACHE_TYPE = 'SimpleCache' + CACHE_DEFAULT_TIMEOUT = 600 + + # IP-based login rate limit (fixed window). Defense in depth atop the + # per-account lockout. Backed by the existing cache extension. + AUTH_RATELIMIT_ENABLED = os.environ.get( + 'AUTH_RATELIMIT_ENABLED', 'true').lower() == 'true' + AUTH_RATELIMIT_MAX = int(os.environ.get('AUTH_RATELIMIT_MAX', 30)) + AUTH_RATELIMIT_WINDOW_SECONDS = int( + os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300)) + + DEFAULT_PAGE_SIZE = 20 + MAX_PAGE_SIZE = 100 + + +class DevelopmentConfig(Config): + """Development configuration.""" + + DEBUG = True + SQLALCHEMY_ECHO = True + + SQLALCHEMY_DATABASE_URI = os.environ.get( + 'DATABASE_URL', + 'mysql+pymysql://root:changeme@127.0.0.1:3306/shopdb_flask', + ) + + +class TestingConfig(Config): + """Testing configuration.""" + + TESTING = True + # Off by default so login-heavy fixtures do not trip the limiter; tests + # that exercise rate limiting flip it on via app.config override. + AUTH_RATELIMIT_ENABLED = False + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + SQLALCHEMY_ENGINE_OPTIONS = { + 'connect_args': {'check_same_thread': False}, + 'poolclass': StaticPool, + } + JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5) + + +class ProductionConfig(Config): + """Production configuration. + + Validation is deferred to validate() so that importing this class in a + non-production environment (tests, dev, tooling) does not raise. + create_app() invokes validate() when config_name == 'production' so + a misconfigured production deploy still fails loud at boot. + """ + + DEBUG = False + SQLALCHEMY_ECHO = False + + JWT_COOKIE_SECURE = True + JWT_COOKIE_CSRF_PROTECT = True + + @classmethod + def validate(cls): + """Verify production config is safe. Called from create_app.""" + secret_key = os.environ.get('SECRET_KEY', '') + jwt_secret = os.environ.get('JWT_SECRET_KEY', '') + database_url = os.environ.get('DATABASE_URL', '') + cors_raw = os.environ.get('CORS_ORIGINS', '').strip() + + insecure_defaults = { + 'dev-secret-key-change-in-production', + 'jwt-secret-key-change-in-production', + } + + if not secret_key or secret_key in insecure_defaults: + raise ConfigError( + 'SECRET_KEY is required in production and must not be the ' + 'development default. Set a strong random value in the ' + 'environment before starting the app.' + ) + if not jwt_secret or jwt_secret in insecure_defaults: + raise ConfigError( + 'JWT_SECRET_KEY is required in production and must not be ' + 'the development default. Set a strong random value in the ' + 'environment before starting the app.' + ) + if not database_url: + raise ConfigError( + 'DATABASE_URL is required in production. No fallback to a ' + 'development localhost URL is permitted.' + ) + if not cors_raw or cors_raw == '*': + raise ConfigError( + 'CORS_ORIGINS must be a comma-separated allowlist of ' + 'explicit origins in production. Wildcard "*" is not ' + 'permitted. Example: ' + 'CORS_ORIGINS=https://shopdb.example.com,https://shopdb-mirror.example.com' + ) + + +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig, +} diff --git a/shopdb/core/__init__.py b/shopdb/core/__init__.py new file mode 100644 index 00000000..6f4aecfc --- /dev/null +++ b/shopdb/core/__init__.py @@ -0,0 +1 @@ +"""Core module - always loaded.""" diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py new file mode 100644 index 00000000..2a699d3f --- /dev/null +++ b/shopdb/core/api/__init__.py @@ -0,0 +1,51 @@ +"""Core API blueprints.""" + +from .auth import auth_bp +from .assets import assets_bp +from .modeltypes import modeltypes_bp +from .plugins import plugins_bp +from .vendors import vendors_bp +from .models import models_bp +from .businessunits import businessunits_bp +from .locations import locations_bp +from .operatingsystems import operatingsystems_bp +from .dashboard import dashboard_bp +from .dashboarddefaults import dashboarddefaults_bp +from .applications import applications_bp +from .supportteams import supportteams_bp +from .search import search_bp +from .reports import reports_bp +from .collector import collector_bp +from .settings import settings_bp +from .auditlogs import auditlogs_bp +from .users import users_bp +from .customfields import customfields_bp +from .setup import setup_bp +from .pluginui import pluginui_bp +from .apitokens import apitokens_bp + +__all__ = [ + 'auth_bp', + 'assets_bp', + 'modeltypes_bp', + 'plugins_bp', + 'vendors_bp', + 'models_bp', + 'businessunits_bp', + 'locations_bp', + 'operatingsystems_bp', + 'dashboard_bp', + 'dashboarddefaults_bp', + 'applications_bp', + 'supportteams_bp', + 'search_bp', + 'reports_bp', + 'collector_bp', + 'settings_bp', + 'auditlogs_bp', + 'users_bp', + 'customfields_bp', + 'setup_bp', + 'pluginui_bp', + 'apitokens_bp', +] diff --git a/shopdb/core/api/apitokens.py b/shopdb/core/api/apitokens.py new file mode 100644 index 00000000..c06b5c79 --- /dev/null +++ b/shopdb/core/api/apitokens.py @@ -0,0 +1,171 @@ +"""Personal API token management endpoints. + +Any authenticated user may manage their OWN tokens; an admin may list or revoke +anyone's. Endpoints are jwt_required (a token must be bootstrapped from a real +login or an existing token). The full secret is returned ONCE, on create. +""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, current_user + +from shopdb.extensions import db +from shopdb.core.models import ApiToken, AuditLog +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_permission +from shopdb.utils.import_mode import parse_import_datetime + +apitokens_bp = Blueprint('apitokens', __name__) + + +def _validate_scopes(scopes, owner): + """Validate a scopes payload against the token OWNER. Return + (scopelist_or_none, error_response); the second is None when valid. + + scopes may be None/absent (unscoped) or a list of permission names. Every + name must exist in the catalog AND be held by the owner (the scope ceiling): + a token can never grant more than its owner holds. Admins hold everything. + """ + if scopes is None: + return None, None + if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes): + return None, error_response( + ErrorCodes.VALIDATION_ERROR, + 'scopes must be a list of permission names') + unknown = ApiToken.unknown_scope_names(scopes) + if unknown: + return None, error_response( + ErrorCodes.VALIDATION_ERROR, + 'Unknown permission names: ' + ', '.join(unknown)) + # Scope ceiling: the owner must actually hold each scoped permission. + disallowed = [n for n in scopes if not owner.haspermission(n)] + if disallowed: + return None, error_response( + ErrorCodes.VALIDATION_ERROR, + 'Permissions not held by the token owner: ' + ', '.join(disallowed)) + return scopes, None + + +@apitokens_bp.route('', methods=['GET']) +@jwt_required() +def list_apitokens(): + """List the caller's own tokens. Admins may pass ?all=true for everyone's. + + Never returns hashes or secrets. + """ + wants_all = request.args.get('all', 'false').lower() == 'true' + is_admin = current_user.hasrole('admin') + + query = ApiToken.query + if wants_all and is_admin: + include_owner = True + else: + query = query.filter(ApiToken.userid == current_user.userid) + include_owner = False + + query = query.order_by(ApiToken.createddate.desc()) + tokens = [t.to_dict(include_owner=include_owner) for t in query.all()] + return success_response(tokens) + + +@apitokens_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('apitokens.create') +def create_apitoken(): + """Create a token for the caller. Returns the full secret ONCE. + + Optional scopes limit the token to a subset of the caller's own + permissions; absent/null means an unscoped token acting as the caller. + """ + data = request.get_json() or {} + + name = (data.get('name') or '').strip() + if not name: + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + expiresat = None + if data.get('expiresat'): + expiresat = parse_import_datetime(data.get('expiresat')) + if expiresat is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'expiresat is not a valid date/datetime') + + scopelist, scope_error = _validate_scopes(data.get('scopes'), current_user) + if scope_error is not None: + return scope_error + + secret = ApiToken.generate_secret() + token = ApiToken( + userid=current_user.userid, + name=name, + tokenprefix=ApiToken.prefix_of(secret), + tokenhash=ApiToken.hash_secret(secret), + expiresat=expiresat, + ) + token.scopelist = scopelist + db.session.add(token) + db.session.flush() + + AuditLog.log('created', 'ApiToken', entityid=token.tokenid, entityname=name) + db.session.commit() + + result = token.to_dict() + # The secret appears here and NOWHERE else, ever. Not stored, not logged. + result['secret'] = secret + result['warning'] = ('Save this token now. It will not be shown again. ' + 'Store it somewhere safe.') + return success_response(result, message='Token created', http_code=201) + + +@apitokens_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('apitokens.create') +def update_apitoken(tokenid: int): + """Rename, rescope, or deactivate a token. Own token, or any if admin. + + Scopes are validated against the token OWNER's permissions (the ceiling), + not the editor's - so an admin rescoping someone else's token still cannot + grant that owner more than the owner holds. + """ + token = db.session.get(ApiToken, tokenid) + if token is None: + return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404) + + if token.userid != current_user.userid and not current_user.hasrole('admin'): + return error_response(ErrorCodes.FORBIDDEN, + 'You may only manage your own tokens', http_code=403) + + data = request.get_json() or {} + if 'name' in data: + newname = (data.get('name') or '').strip() + if not newname: + return error_response(ErrorCodes.VALIDATION_ERROR, 'name cannot be empty') + token.name = newname + if 'isactive' in data: + token.isactive = bool(data['isactive']) + if 'scopes' in data: + scopelist, scope_error = _validate_scopes(data.get('scopes'), token.user) + if scope_error is not None: + return scope_error + token.scopelist = scopelist + + db.session.commit() + return success_response(token.to_dict(), message='Token updated') + + +@apitokens_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('apitokens.create') +def revoke_apitoken(tokenid: int): + """Revoke (deactivate) a token. Own token, or any if admin.""" + token = db.session.get(ApiToken, tokenid) + if token is None: + return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404) + + if token.userid != current_user.userid and not current_user.hasrole('admin'): + return error_response(ErrorCodes.FORBIDDEN, + 'You may only manage your own tokens', http_code=403) + + token.isactive = False + AuditLog.log('deleted', 'ApiToken', entityid=token.tokenid, entityname=token.name) + db.session.commit() + return success_response(message='Token revoked') diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py new file mode 100644 index 00000000..8e4d1bad --- /dev/null +++ b/shopdb/core/api/applications.py @@ -0,0 +1,462 @@ +"""Applications API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ( + Application, AppVersion, 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.utils.import_mode import apply_import_timestamps + + +def _computer_models(): + """Lazily import the computers plugin models, or None if unavailable. + + Application install-tracking is a join over the computers plugin's tables. + Importing lazily keeps the applications API importable when the computers + plugin is absent or disabled. + """ + try: + from plugins.computers.models import Computer, ComputerInstalledApp + return Computer, ComputerInstalledApp + except ImportError: + return None + + +def _installed_count(appid): + """Count active installs of an app, 0 when the computers plugin is absent.""" + models = _computer_models() + if not models: + return 0 + _, ComputerInstalledApp = models + return ComputerInstalledApp.query.filter_by(appid=appid, isactive=True).count() + + +def _related_kb(appid): + """Knowledge Base articles linked to this app, [] when the KB plugin is + absent. Lazy + guarded so core stays decoupled from the plugin.""" + try: + from plugins.knowledgebase.models import KnowledgeBase + except ImportError: + return [] + rows = KnowledgeBase.query.filter_by(appid=appid, isactive=True).order_by( + KnowledgeBase.shortdescription).all() + return [{'linkid': k.linkid, 'shortdescription': k.shortdescription, + 'linkurl': k.linkurl, 'keywords': k.keywords} for k in rows] + + +def _require_computer_models(): + """Resolve (Computer, ComputerInstalledApp) or a 503 response tuple. + + Usage: `models, err = _require_computer_models(); if err: return err`. + """ + models = _computer_models() + if not models: + return None, error_response( + ErrorCodes.INTERNAL_ERROR, + 'Install tracking requires the computers plugin', + http_code=503) + return models, None + + +from shopdb.utils.authz import require_permission + +applications_bp = Blueprint('applications', __name__) + + +@applications_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_applications(): + """List all applications.""" + page, per_page = get_pagination_params(request) + + query = Application.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Application.isactive == True) + + # Filter out hidden unless specifically requested + if request.args.get('showhidden', 'false').lower() != 'true': + query = query.filter(Application.ishidden == False) + + # Filter by installable + if request.args.get('installable') is not None: + installable = request.args.get('installable').lower() == 'true' + query = query.filter(Application.isinstallable == installable) + + # Exact-match natural-key lookup for idempotent import (app name). + if exactappname := request.args.get('appname'): + query = query.filter(Application.appname == exactappname) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + Application.appname.ilike(f'%{search}%'), + Application.appdescription.ilike(f'%{search}%') + ) + ) + + query = query.order_by(Application.appname) + + items, total = paginate_query(query, page, per_page) + data = [] + for app in items: + # to_dict already flattens supportteamname/teamurl/contacts. + app_dict = app.to_dict() + app_dict['installedcount'] = _installed_count(app.appid) + data.append(app_dict) + + return paginated_response(data, page, per_page, total) + + +@applications_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_application(app_id: int): + """Get a single application with details.""" + app = db.session.get(Application, app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = app.to_dict() + data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()] + data['installedcount'] = _installed_count(app.appid) + data['knowledgebase'] = _related_kb(app.appid) + + return success_response(data) + + +@applications_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('applications.create') +def create_application(): + """Create a new application.""" + data = request.get_json() + + if not data or not data.get('appname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appname is required') + + if Application.query.filter_by(appname=data['appname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Application '{data['appname']}' already exists", + http_code=409 + ) + + app = Application( + appname=data['appname'], + appdescription=data.get('appdescription'), + supportteamid=data.get('supportteamid'), + isinstallable=data.get('isinstallable', False), + applicationnotes=data.get('applicationnotes'), + installpath=data.get('installpath'), + applicationlink=data.get('applicationlink'), + documentationpath=data.get('documentationpath'), + ishidden=data.get('ishidden', False), + isprinter=data.get('isprinter', False), + islicenced=data.get('islicenced', False), + isrequired=data.get('isrequired', False), + image=data.get('image') + ) + + db.session.add(app) + apply_import_timestamps(app, data) + db.session.flush() + + AuditLog.log('created', 'Application', entityid=app.appid, entityname=app.appname) + + db.session.commit() + + return success_response(app.to_dict(), message='Application created', http_code=201) + + +@applications_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('applications.edit') +def update_application(app_id: int): + """Update an application.""" + app = db.session.get(Application, app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'appname' in data and data['appname'] != app.appname: + if Application.query.filter_by(appname=data['appname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Application '{data['appname']}' already exists", + http_code=409 + ) + + fields = [ + 'appname', 'appdescription', 'supportteamid', 'isinstallable', + 'applicationnotes', 'installpath', 'applicationlink', 'documentationpath', + 'ishidden', 'isprinter', 'islicenced', 'isrequired', 'image', 'isactive' + ] + + changes = {} + for key in fields: + if key in data: + old_val = getattr(app, key) + new_val = data[key] + if old_val != new_val: + changes[key] = {'old': old_val, 'new': new_val} + setattr(app, key, data[key]) + + if changes: + AuditLog.log('updated', 'Application', entityid=app.appid, + entityname=app.appname, changes=changes) + + apply_import_timestamps(app, data) + db.session.commit() + return success_response(app.to_dict(), message='Application updated') + + +@applications_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('applications.delete') +def delete_application(app_id: int): + """Delete (deactivate) an application.""" + app = db.session.get(Application, app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + app.isactive = False + + AuditLog.log('deleted', 'Application', entityid=app.appid, entityname=app.appname) + + db.session.commit() + + return success_response(message='Application deleted') + + +# ---- Versions ---- + +@applications_bp.route('//versions', methods=['GET']) +@jwt_required(optional=True) +def list_versions(app_id: int): + """List all versions for an application.""" + app = db.session.get(Application, app_id) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + versions = app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all() + return success_response([v.to_dict() for v in versions]) + + +@applications_bp.route('//versions', methods=['POST']) +@jwt_required() +@require_permission('applications.create') +def create_version(app_id: int): + """Create a new version for an application.""" + app = db.session.get(Application, app_id) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = request.get_json() + if not data or not data.get('version'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'version is required') + + if AppVersion.query.filter_by(appid=app_id, version=data['version']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Version '{data['version']}' already exists for this application", + http_code=409 + ) + + version = AppVersion( + appid=app_id, + version=data['version'], + releasedate=data.get('releasedate'), + notes=data.get('notes') + ) + + db.session.add(version) + # Preserve the legacy release/added timestamps in import mode (no-op + # otherwise), so imported version history keeps its original dates. + apply_import_timestamps(version, data) + db.session.commit() + + return success_response(version.to_dict(), message='Version created', http_code=201) + + +# ---- Computers with this app installed ---- + +@applications_bp.route('//installed', methods=['GET']) +@jwt_required(optional=True) +def list_installed_machines(app_id: int): + """List all computers that have this application installed.""" + app = db.session.get(Application, app_id) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + models, err = _require_computer_models() + if err: + return err + _, ComputerInstalledApp = models + + installed = ComputerInstalledApp.query.filter_by( + appid=app_id, isactive=True).all() + data = [] + for i in installed: + comp = i.computer + version = i.installedversion + if not version and i.appversion: + version = i.appversion.version + item = { + 'id': i.id, + 'computerid': i.computerid, + 'version': version, + } + if comp: + item['computer'] = { + 'computerid': comp.computerid, + 'assetnumber': comp.asset.assetnumber if comp.asset else None, + 'hostname': comp.hostname, + } + data.append(item) + + return success_response(data) + + +# ---- Installed Apps (per computer) ---- + +@applications_bp.route('/machines/', methods=['GET']) +@jwt_required(optional=True) +def list_machine_applications(machine_id: int): + """List all applications installed on a computer.""" + models, err = _require_computer_models() + if err: + return err + Computer, ComputerInstalledApp = models + + comp = db.session.get(Computer, machine_id) + if not comp: + return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404) + + installed = comp.installedapps.filter_by(isactive=True).all() + return success_response([i.to_dict() for i in installed]) + + +@applications_bp.route('/machines/', methods=['POST']) +@jwt_required() +@require_permission('applications.create') +def install_application(machine_id: int): + """Install an application on a computer.""" + models, err = _require_computer_models() + if err: + return err + Computer, ComputerInstalledApp = models + + comp = db.session.get(Computer, machine_id) + if not comp: + return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404) + + data = request.get_json() + if not data or not data.get('appid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required') + + app = db.session.get(Application, data['appid']) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + existing = ComputerInstalledApp.query.filter_by( + computerid=machine_id, + appid=data['appid'] + ).first() + + if existing: + if existing.isactive: + return error_response( + ErrorCodes.CONFLICT, + 'Application already installed on this computer', + http_code=409 + ) + existing.isactive = True + existing.appversionid = data.get('appversionid') + existing.installeddate = db.func.now() + db.session.commit() + return success_response(existing.to_dict(), message='Application reinstalled') + + installed = ComputerInstalledApp( + computerid=machine_id, + appid=data['appid'], + appversionid=data.get('appversionid') + ) + + db.session.add(installed) + db.session.commit() + + return success_response(installed.to_dict(), message='Application installed', http_code=201) + + +@applications_bp.route('/machines//', methods=['DELETE']) +@jwt_required() +@require_permission('applications.delete') +def uninstall_application(machine_id: int, app_id: int): + """Uninstall an application from a computer.""" + models, err = _require_computer_models() + if err: + return err + _, ComputerInstalledApp = models + + installed = ComputerInstalledApp.query.filter_by( + computerid=machine_id, + appid=app_id, + isactive=True + ).first() + + if not installed: + return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this computer', http_code=404) + + installed.isactive = False + db.session.commit() + + return success_response(message='Application uninstalled') + + +@applications_bp.route('/machines//', methods=['PUT']) +@jwt_required() +@require_permission('applications.edit') +def update_installed_app(machine_id: int, app_id: int): + """Update installed application (e.g., change version).""" + models, err = _require_computer_models() + if err: + return err + _, ComputerInstalledApp = models + + installed = ComputerInstalledApp.query.filter_by( + computerid=machine_id, + appid=app_id, + isactive=True + ).first() + + if not installed: + return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this computer', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'appversionid' in data: + installed.appversionid = data['appversionid'] + + db.session.commit() + + return success_response(installed.to_dict(), message='Installation updated') + +# Support teams + contacts now live in the supportteams blueprint +# (/api/supportteams), replacing the legacy appowners pair. diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py new file mode 100644 index 00000000..2403765d --- /dev/null +++ b/shopdb/core/api/assets.py @@ -0,0 +1,1170 @@ +"""Assets API endpoints - unified asset queries.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required +from sqlalchemy.orm import joinedload, subqueryload + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_permission +from shopdb.utils.import_mode import apply_import_timestamps + +assets_bp = Blueprint('assets', __name__) + + +# ============================================================================= +# Asset Types +# ============================================================================= + +@assets_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_asset_types(): + """List all asset types.""" + page, per_page = get_pagination_params(request) + + query = AssetType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AssetType.isactive == True) + + query = query.order_by(AssetType.assettype) + + items, total = paginate_query(query, page, per_page) + data = [t.to_dict() for t in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/types/', methods=['GET']) +@jwt_required(optional=True) +def get_asset_type(type_id: int): + """Get a single asset type.""" + t = db.session.get(AssetType, type_id) + + if not t: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset type with ID {type_id} not found', + http_code=404 + ) + + return success_response(t.to_dict()) + + +@assets_bp.route('/types', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_asset_type(): + """Create a new asset type.""" + data = request.get_json() + + if not data or not data.get('assettype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettype is required') + + if AssetType.query.filter_by(assettype=data['assettype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset type '{data['assettype']}' already exists", + http_code=409 + ) + + t = AssetType( + assettype=data['assettype'], + pluginname=data.get('pluginname'), + tablename=data.get('tablename'), + description=data.get('description'), + icon=data.get('icon'), + color=data.get('color') + ) + + db.session.add(t) + db.session.commit() + + return success_response(t.to_dict(), message='Asset type created', http_code=201) + + +@assets_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_permission('assets.edit') +def update_asset_type(type_id: int): + """Update an asset type's display fields (color/icon/description). The name, + plugin, and table are structural and not editable here.""" + t = db.session.get(AssetType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Asset type not found', http_code=404) + data = request.get_json() or {} + for key in ('description', 'icon', 'color', 'isactive'): + if key in data: + setattr(t, key, data[key]) + db.session.commit() + return success_response(t.to_dict(), message='Asset type updated') + + +# ============================================================================= +# Asset Statuses +# ============================================================================= + +@assets_bp.route('/statuses', methods=['GET']) +@jwt_required(optional=True) +def list_asset_statuses(): + """List all asset statuses.""" + page, per_page = get_pagination_params(request) + + query = AssetStatus.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(AssetStatus.isactive == True) + + query = query.order_by(AssetStatus.status) + + items, total = paginate_query(query, page, per_page) + data = [s.to_dict() for s in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/statuses/', methods=['GET']) +@jwt_required(optional=True) +def get_asset_status(status_id: int): + """Get a single asset status.""" + s = db.session.get(AssetStatus, status_id) + + if not s: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset status with ID {status_id} not found', + http_code=404 + ) + + return success_response(s.to_dict()) + + +@assets_bp.route('/statuses', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_asset_status(): + """Create a new asset status.""" + data = request.get_json() + + if not data or not data.get('status'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required') + + if AssetStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset status '{data['status']}' already exists", + http_code=409 + ) + + s = AssetStatus( + status=data['status'], + description=data.get('description'), + color=data.get('color') + ) + + db.session.add(s) + db.session.commit() + + return success_response(s.to_dict(), message='Asset status created', http_code=201) + + +@assets_bp.route('/statuses/', methods=['PUT']) +@jwt_required() +@require_permission('assets.edit') +def update_asset_status(status_id: int): + """Update an asset status.""" + s = db.session.get(AssetStatus, status_id) + if not s: + return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', + http_code=404) + + data = request.get_json() or {} + + # Conflict check on rename + if 'status' in data and data['status'] != s.status: + if AssetStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset status '{data['status']}' already exists", + http_code=409 + ) + + for key in ('status', 'description', 'color', 'isactive'): + if key in data: + setattr(s, key, data[key]) + + db.session.commit() + return success_response(s.to_dict(), message='Asset status updated') + + +@assets_bp.route('/statuses/', methods=['DELETE']) +@jwt_required() +@require_permission('assets.delete') +def delete_asset_status(status_id: int): + """Delete an asset status. Refused if any asset still uses it.""" + s = db.session.get(AssetStatus, status_id) + if not s: + return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', + http_code=404) + + inuse = Asset.query.filter_by(statusid=status_id).count() + if inuse: + return error_response( + ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} asset(s) still use this status", + http_code=409 + ) + + db.session.delete(s) + db.session.commit() + return success_response(message='Asset status deleted') + + +# ============================================================================= +# Relationship Types +# ============================================================================= + +@assets_bp.route('/relationshiptypes', methods=['GET']) +@jwt_required(optional=True) +def list_relationship_types(): + """List all asset relationship types.""" + types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all() + return success_response([_rel_type_dict(t) for t in types]) + + +@assets_bp.route('/relationshiptypes', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_relationship_type(): + """Create a new asset relationship type.""" + data = request.get_json() + if not data or not data.get('relationshiptype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required') + + if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Relationship type '{data['relationshiptype']}' already exists", + http_code=409 + ) + + rel_type = RelationshipType( + relationshiptype=data['relationshiptype'], + description=data.get('description'), + color=data.get('color'), + isdirectional=data.get('isdirectional', True) + ) + db.session.add(rel_type) + db.session.commit() + + return success_response(_rel_type_dict(rel_type), message='Relationship type created', http_code=201) + + +def _rel_type_dict(t): + return { + 'relationshiptypeid': t.relationshiptypeid, + 'relationshiptype': t.relationshiptype, + 'description': t.description, + 'color': t.color, + 'isdirectional': t.isdirectional, + # read-only view of the propagation rails (seed/CLI-managed, no UI) + 'propagatesthrough': [ + { + 'relationshiptypeid': p.relationshiptypeid, + 'relationshiptype': p.relationshiptype, + 'isdirectional': p.isdirectional, + } + for p in t.propagatesthrough + ], + } + + +@assets_bp.route('/relationshiptypes/', methods=['PUT']) +@jwt_required() +@require_permission('assets.edit') +def update_relationship_type(type_id: int): + """Update a relationship type.""" + t = db.session.get(RelationshipType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404) + data = request.get_json() or {} + if 'relationshiptype' in data and data['relationshiptype'] != t.relationshiptype: + if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Relationship type '{data['relationshiptype']}' already exists", http_code=409) + for key in ('relationshiptype', 'description', 'color', 'isdirectional'): + if key in data: + setattr(t, key, data[key]) + db.session.commit() + return success_response(_rel_type_dict(t), message='Relationship type updated') + + +@assets_bp.route('/relationshiptypes/', methods=['DELETE']) +@jwt_required() +@require_permission('assets.delete') +def delete_relationship_type(type_id: int): + """Delete a relationship type. Refused if any relationship still uses it.""" + t = db.session.get(RelationshipType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404) + inuse = AssetRelationship.query.filter_by(relationshiptypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} relationship(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Relationship type deleted') + + +# ============================================================================= +# Assets +# ============================================================================= + +@assets_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_assets(): + """ + List all assets with filtering and pagination. + + Query parameters: + - page: Page number (default: 1) + - per_page: Items per page (default: 20, max: 100) + - active: Filter by active status (default: true) + - search: Search by assetnumber or name + - type: Filter by asset type name (e.g., 'machine', 'computer') + - type_id: Filter by asset type ID + - status_id: Filter by status ID + - location_id: Filter by location ID + - businessunit_id: Filter by business unit ID + - include_type_data: Include category-specific extension data (default: false) + """ + page, per_page = get_pagination_params(request) + + query = Asset.query + + # Active filter + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Asset.isactive == True) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + # Type filter by name + if type_name := request.args.get('type'): + query = query.join(AssetType).filter(AssetType.assettype == type_name) + + # Type filter by ID + if type_id := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Asset.assettypeid == int(type_id)) + + # Status filter + if status_id := request.args.get('statusid', request.args.get('status_id')): + query = query.filter(Asset.statusid == int(status_id)) + + # Location filter + if location_id := request.args.get('locationid', request.args.get('location_id')): + query = query.filter(Asset.locationid == int(location_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Sorting + sort_by = request.args.get('sort', 'assetnumber') + sort_dir = request.args.get('dir', 'asc') + + sort_columns = { + 'assetnumber': Asset.assetnumber, + 'name': Asset.name, + 'createddate': Asset.createddate, + 'modifieddate': Asset.modifieddate, + } + + if sort_by in sort_columns: + col = sort_columns[sort_by] + query = query.order_by(col.desc() if sort_dir == 'desc' else col) + else: + query = query.order_by(Asset.assetnumber) + + items, total = paginate_query(query, page, per_page) + + # Include type data if requested + include_type_data = request.args.get('include_type_data', 'false').lower() == 'true' + data = [a.to_dict(include_type_data=include_type_data) for a in items] + + return paginated_response(data, page, per_page, total) + + +@assets_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_asset(asset_id: int): + """ + Get a single asset with full details. + + Query parameters: + - include_type_data: Include category-specific extension data (default: true) + """ + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + include_type_data = request.args.get('include_type_data', 'true').lower() != 'false' + return success_response(asset.to_dict(include_type_data=include_type_data)) + + +@assets_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_asset(): + """Create a new asset.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + if not data.get('assetnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required') + if not data.get('assettypeid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid is required') + + # Check for duplicate assetnumber + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Validate foreign keys exist + if not db.session.get(AssetType, data['assettypeid']): + return error_response( + ErrorCodes.VALIDATION_ERROR, + f"Asset type with ID {data['assettypeid']} not found" + ) + + asset = Asset( + assetnumber=data['assetnumber'], + name=data.get('name'), + serialnumber=data.get('serialnumber'), + assettypeid=data['assettypeid'], + statusid=data.get('statusid', 1), + locationid=data.get('locationid'), + businessunitid=data.get('businessunitid'), + mapx=data.get('mapx'), + mapy=data.get('mapy'), + notes=data.get('notes') + ) + + db.session.add(asset) + apply_import_timestamps(asset, data) + db.session.commit() + + return success_response(asset.to_dict(), message='Asset created', http_code=201) + + +@assets_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('assets.edit') +def update_asset(asset_id: int): + """Update an asset.""" + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for conflicting assetnumber + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: + if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset with number '{data['assetnumber']}' already exists", + http_code=409 + ) + + # Update allowed fields + allowed_fields = [ + 'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive' + ] + + for key in allowed_fields: + if key in data: + setattr(asset, key, data[key]) + + apply_import_timestamps(asset, data) + db.session.commit() + return success_response(asset.to_dict(), message='Asset updated') + + +@assets_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_permission('assets.delete') +def delete_asset(asset_id: int): + """Delete (soft delete) an asset.""" + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + asset.isactive = False + db.session.commit() + + return success_response(message='Asset deleted') + + +@assets_bp.route('/lookup/', methods=['GET']) +@jwt_required(optional=True) +def lookup_asset_by_number(assetnumber: str): + """ + Look up an asset by its asset number. + + Useful for finding the asset ID when you only have the machine/asset number. + """ + asset = Asset.query.filter_by(assetnumber=assetnumber, isactive=True).first() + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with number {assetnumber} not found', + http_code=404 + ) + + return success_response(asset.to_dict(include_type_data=True)) + + +# ============================================================================= +# Asset Relationships +# ============================================================================= + +def _partners_via(assetid, throughtypeid): + """Return the set of asset ids linked to assetid via throughtypeid. + + Direction-blind: matches rows where assetid is source OR target, so a + symmetric through-type (Dualpath) stored in either direction is caught. + """ + rows = AssetRelationship.query.filter( + AssetRelationship.relationshiptypeid == throughtypeid, + AssetRelationship.isactive == True, + db.or_( + AssetRelationship.sourceassetid == assetid, + AssetRelationship.targetassetid == assetid, + ), + ).all() + partners = set() + for row in rows: + other = row.targetassetid if row.sourceassetid == assetid else row.sourceassetid + partners.add(other) + return partners + + +def _relationship_exists(sourceid, targetid, typeid): + # existence check ignores isactive so a soft-deleted row still blocks a + # duplicate insert (the uniqueness constraint spans all rows). + return AssetRelationship.query.filter_by( + sourceassetid=sourceid, + targetassetid=targetid, + relationshiptypeid=typeid, + ).first() is not None + + +def _add_propagated(sourceid, targetid, rel, created): + # idempotent add; skip if source-T->target already exists (any isactive) + if sourceid in (targetid,): + return + if _relationship_exists(sourceid, targetid, rel.relationshiptypeid): + return + newrel = AssetRelationship( + sourceassetid=sourceid, + targetassetid=targetid, + relationshiptypeid=rel.relationshiptypeid, + label=rel.label, + ) + db.session.add(newrel) + created.append(newrel) + + +def propagate_relationship(rel): + """Fan out rel across the SYMMETRIC through-types of its type. + + A Dualpath pair is one physical dual-bay machine, so a relationship that + touches one bay must be mirrored to the partner bay - whichever end (source + or target) carries the Dualpath link. For each symmetric through-type P + (isdirectional false) in the type's propagation set we mirror BOTH ends, + keeping the relationship's direction: + + * for each partner X of the TARGET via P: create source -T-> X + * for each partner X of the SOURCE via P: create X -T-> target + + In practice only one end has partners (the bay end of a controls edge, + whichever direction the row is stored), so there is no double fan-out. + Canonical controls direction is PC -> machine (the PC is the controller: + it sends programs to the machine and receives logs); reversed legacy rows + are corrected by `flask relationships fix-controls-direction`, and + propagation preserves whatever direction the row carries. Same-direction + idempotency: controls is directional, so we skip when the same + source-T->target row already exists. Returns the newly created + AssetRelationship rows (added to the session, not committed). + + Directional through-types (partof) are declared but NOT consumed: the + parent/child fan-out direction is ambiguous, so their propagation semantics + are deferred and partof stays inert exactly as before. + """ + created = [] + reltype = rel.relationshiptype or db.session.get(RelationshipType, rel.relationshiptypeid) + if not reltype: + return created + for through in reltype.propagatesthrough: + if through.isdirectional: + continue # directional propagation deferred (parent/child ambiguity) + # target-side partners inherit being controlled BY the source + for partnerid in _partners_via(rel.targetassetid, through.relationshiptypeid): + if partnerid != rel.sourceassetid: + _add_propagated(rel.sourceassetid, partnerid, rel, created) + # source-side partners inherit controlling the target + for partnerid in _partners_via(rel.sourceassetid, through.relationshiptypeid): + if partnerid != rel.targetassetid: + _add_propagated(partnerid, rel.targetassetid, rel, created) + return created + + +@assets_bp.route('//relationships', methods=['GET']) +@jwt_required(optional=True) +def get_asset_relationships(asset_id: int): + """ + Get all relationships for an asset. + + Returns both outgoing (source) and incoming (target) relationships. + """ + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + # Get outgoing relationships (this asset is source) + outgoing = AssetRelationship.query.filter_by( + sourceassetid=asset_id + ).filter(AssetRelationship.isactive == True).all() + + # Get incoming relationships (this asset is target) + incoming = AssetRelationship.query.filter_by( + targetassetid=asset_id + ).filter(AssetRelationship.isactive == True).all() + + outgoing_data = [] + for rel in outgoing: + r = rel.to_dict() + r['targetasset'] = rel.targetasset.to_dict() if rel.targetasset else None + r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None + # directionality drives card display (symmetric collapse vs arrows) + r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True + outgoing_data.append(r) + + incoming_data = [] + for rel in incoming: + r = rel.to_dict() + r['sourceasset'] = rel.sourceasset.to_dict() if rel.sourceasset else None + r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None + r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True + incoming_data.append(r) + + return success_response({ + 'outgoing': outgoing_data, + 'incoming': incoming_data + }) + + +@assets_bp.route('/relationships', methods=['POST']) +@jwt_required() +@require_permission('assets.create') +def create_asset_relationship(): + """Create a relationship between two assets. + + After the row is created, the relationship is fanned out across its type's + propagation rails (relationshiptypepropagations). For each SYMMETRIC + through-type (e.g. Dualpath) the edge is mirrored to the partner of + whichever endpoint carries the through-type link, keeping direction. + Concretely, `controls` between a bay of a dual-bay machine and its PC is + also created for the partner bay, since a Dualpath pair is one physical + machine with a single controller. Directional through-types (partof) are + declared but not consumed yet. The response carries `propagated` and + `propagatedcount`. Propagation pairs are seed/CLI-managed (there is no + management UI); backfill legacy data with `flask relationships propagate`. + """ + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate required fields + required = ['sourceassetid', 'targetassetid', 'relationshiptypeid'] + for field in required: + if not data.get(field): + return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required') + + source_id = data['sourceassetid'] + target_id = data['targetassetid'] + type_id = data['relationshiptypeid'] + + # Validate assets exist + if not db.session.get(Asset, source_id): + return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404) + if not db.session.get(Asset, target_id): + return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404) + if not db.session.get(RelationshipType, type_id): + return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404) + + # Check for duplicate relationship + existing = AssetRelationship.query.filter_by( + sourceassetid=source_id, + targetassetid=target_id, + relationshiptypeid=type_id + ).first() + + if existing: + return error_response( + ErrorCodes.CONFLICT, + 'This relationship already exists', + http_code=409 + ) + + rel = AssetRelationship( + sourceassetid=source_id, + targetassetid=target_id, + relationshiptypeid=type_id, + notes=data.get('notes') + ) + + db.session.add(rel) + apply_import_timestamps(rel, data) + db.session.flush() + + # fan out across symmetric propagation rails (e.g. Dualpath partner bays) + propagated = propagate_relationship(rel) + for prop in propagated: + apply_import_timestamps(prop, data) + db.session.commit() + + payload = rel.to_dict() + payload['propagated'] = [prop.to_dict() for prop in propagated] + payload['propagatedcount'] = len(propagated) + return success_response(payload, message='Relationship created', http_code=201) + + +@assets_bp.route('/relationships/', methods=['DELETE']) +@jwt_required() +@require_permission('assets.delete') +def delete_asset_relationship(rel_id: int): + """Delete an asset relationship. + + Deletion is manual and per-row: propagated rows (e.g. the mirrored controls + link on a Dualpath partner bay) are real independent rows and each is + deleted on its own. Removing one bay's link does NOT cascade to the other. + """ + rel = db.session.get(AssetRelationship, rel_id) + + if not rel: + return error_response( + ErrorCodes.NOT_FOUND, + f'Relationship with ID {rel_id} not found', + http_code=404 + ) + + rel.isactive = False + db.session.commit() + + return success_response(message='Relationship deleted') + + +# ============================================================================= +# Asset Communications +# ============================================================================= + +# ============================================================================= +# Unified Asset Map +# ============================================================================= + +@assets_bp.route('/map', methods=['GET']) +@jwt_required(optional=True) +def get_assets_map(): + """ + Get all assets with map positions for unified floor map display. + + Returns assets with mapx/mapy coordinates, joined with type-specific data. + + Query parameters: + - assettype: Filter by asset type name (machine, computer, network_device, printer) + - subtype: Filter by subtype ID (machinetype for machines, computertype for PCs, networkdevicetype for network, printertype for printer) + - businessunitid: Filter by business unit ID + - statusid: Filter by status ID + - locationid: Filter by location ID + - search: Search by assetnumber, name, or serialnumber + """ + from shopdb.core.models import Location, BusinessUnit, Communication + + # Eager-load all relationships to avoid N+1 queries. + # Core relationships via joinedload, extension tables via subqueryload + # with their nested relationships (vendor, model, type) also eager-loaded. + eager_options = [ + joinedload(Asset.assettype), + joinedload(Asset.status), + joinedload(Asset.location), + joinedload(Asset.businessunit), + ] + + # Eager-load plugin extension tables AND their relationships + try: + from plugins.machines.models import Machine + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.machinetype) + ) + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.vendor) + ) + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.model) + ) + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.controllervendor) + ) + eager_options.append( + subqueryload(Asset.machine) + .joinedload(Machine.controllermodel) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.computers.models import Computer + eager_options.append( + subqueryload(Asset.computer) + .joinedload(Computer.computertype) + ) + eager_options.append( + subqueryload(Asset.computer) + .joinedload(Computer.operatingsystem) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.network.models import NetworkDevice + eager_options.append( + subqueryload(Asset.network_device) + .joinedload(NetworkDevice.networkdevicetype) + ) + eager_options.append( + subqueryload(Asset.network_device) + .joinedload(NetworkDevice.vendor) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.printers.models import Printer + eager_options.append( + subqueryload(Asset.printer) + .joinedload(Printer.printertype) + ) + eager_options.append( + subqueryload(Asset.printer) + .joinedload(Printer.vendor) + ) + eager_options.append( + subqueryload(Asset.printer) + .joinedload(Printer.model) + ) + except (ImportError, AttributeError): + pass + try: + from plugins.measuringtools.models import MeasuringTool + eager_options.append( + subqueryload(Asset.measuringtool) + .joinedload(MeasuringTool.measuringtooltype) + ) + except (ImportError, AttributeError): + pass + + query = Asset.query.options(*eager_options).filter( + Asset.isactive == True, + Asset.mapx.isnot(None), + Asset.mapy.isnot(None) + ) + + # Dualpath single-machine collapse: hide the secondary bay marker so a + # dual-bay pair shows one marker; the primary carries the partner label. + # Gated on the site setting (default on). + from shopdb.core.services.dualpath import ( + resolve_dualpath_pairs, dualpath_single_machine_enabled) + dualpath_collapse = None + if dualpath_single_machine_enabled(): + dualpath_collapse = resolve_dualpath_pairs() + if dualpath_collapse.secondaryassetids: + query = query.filter( + Asset.assetid.notin_(dualpath_collapse.secondaryassetids)) + + selected_assettype = request.args.get('assettype') + + # Filter by asset type name + if selected_assettype: + query = query.join(AssetType).filter(AssetType.assettype == selected_assettype) + + # Filter by subtype (depends on asset type) - case-insensitive matching + if subtype_id := request.args.get('subtype'): + subtype_id = int(subtype_id) + # Normalize the underscore DB form (measuring_tool, network_device) to + # the space form the branches below compare against. + asset_type_lower = ( + selected_assettype.lower().replace('_', ' ') if selected_assettype else '') + if asset_type_lower == 'machine': + try: + from plugins.machines.models import Machine + query = query.join(Machine, Machine.assetid == Asset.assetid).filter( + Machine.machinetypeid == subtype_id + ) + except ImportError: + pass + elif asset_type_lower == 'computer': + try: + from plugins.computers.models import Computer + query = query.join(Computer, Computer.assetid == Asset.assetid).filter( + Computer.computertypeid == subtype_id + ) + except ImportError: + pass + elif asset_type_lower == 'network device': + try: + from plugins.network.models import NetworkDevice + query = query.join(NetworkDevice, NetworkDevice.assetid == Asset.assetid).filter( + NetworkDevice.networkdevicetypeid == subtype_id + ) + except ImportError: + pass + elif asset_type_lower == 'printer': + try: + from plugins.printers.models import Printer + query = query.join(Printer, Printer.assetid == Asset.assetid).filter( + Printer.printertypeid == subtype_id + ) + except ImportError: + pass + elif asset_type_lower == 'measuring tool': + try: + from plugins.measuringtools.models import MeasuringTool + query = query.join( + MeasuringTool, MeasuringTool.assetid == Asset.assetid).filter( + MeasuringTool.measuringtooltypeid == subtype_id + ) + except ImportError: + pass + + # Filter by business unit + if bu_id := request.args.get('businessunitid'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Filter by status + if status_id := request.args.get('statusid'): + query = query.filter(Asset.statusid == int(status_id)) + + # Filter by location + if location_id := request.args.get('locationid'): + query = query.filter(Asset.locationid == int(location_id)) + + # Search filter + if search := request.args.get('search'): + query = query.filter( + db.or_( + Asset.assetnumber.ilike(f'%{search}%'), + Asset.name.ilike(f'%{search}%'), + Asset.serialnumber.ilike(f'%{search}%') + ) + ) + + assets = query.all() + + # Batch-load primary IPs in a single query instead of N+1 per asset. + # Prefer isprimary=True IP, fall back to any IP (comtypeid=1). + asset_ids = [a.assetid for a in assets] + primary_ip_map = {} + if asset_ids: + ip_rows = db.session.query( + Communication.assetid, + Communication.ipaddress, + Communication.isprimary + ).filter( + Communication.assetid.in_(asset_ids), + Communication.comtypeid == 1, + Communication.isactive == True + ).order_by( + Communication.isprimary.desc() + ).all() + + for row in ip_rows: + # First match wins (isprimary=True sorted first) + if row.assetid not in primary_ip_map: + primary_ip_map[row.assetid] = row.ipaddress + + # Build response - all relationship data is already loaded, no extra queries + data = [] + for asset in assets: + item = { + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'name': asset.name, + 'displayname': asset.display_name, + 'serialnumber': asset.serialnumber, + 'mapx': asset.mapx, + 'mapy': asset.mapy, + 'assettype': asset.assettype.assettype if asset.assettype else None, + 'assettypeid': asset.assettypeid, + 'status': asset.status.status if asset.status else None, + 'statusid': asset.statusid, + 'statuscolor': asset.status.color if asset.status else None, + 'location': asset.location.locationname if asset.location else None, + 'locationid': asset.locationid, + 'businessunit': asset.businessunit.businessunit if asset.businessunit else None, + 'businessunitid': asset.businessunitid, + 'primaryip': primary_ip_map.get(asset.assetid), + } + + # Extension data is already eager-loaded via lazy='joined' backrefs + type_data = asset._get_extension_data() + if type_data: + item['typedata'] = type_data + + # combined-label partner for a collapsed dual-bay pair (label only) + if dualpath_collapse: + partner = dualpath_collapse.partnerbyasset.get(asset.assetid) + item['dualpathpartner'] = partner['assetnumber'] if partner else None + + data.append(item) + + # Get filter options - these are small reference tables, no N+1 concern + asset_types = AssetType.query.filter(AssetType.isactive == True).all() + types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon, 'color': t.color} for t in asset_types] + + statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all() + status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses] + + business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all() + bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units] + + locations = Location.query.filter(Location.isactive == True).all() + loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations] + + # Get subtypes for filter dropdowns + subtypes = {} + + try: + from plugins.machines.models import MachineType + machine_types = MachineType.query.filter(MachineType.isactive == True).order_by(MachineType.machinetype).all() + subtypes['Machine'] = [{'id': mt.machinetypeid, 'name': mt.machinetype, 'color': mt.color} for mt in machine_types] + except ImportError: + subtypes['Machine'] = [] + + try: + from plugins.computers.models import ComputerType + computer_types = ComputerType.query.filter(ComputerType.isactive == True).order_by(ComputerType.computertype).all() + subtypes['Computer'] = [{'id': ct.computertypeid, 'name': ct.computertype, 'color': ct.color} for ct in computer_types] + except ImportError: + subtypes['Computer'] = [] + + try: + from plugins.network.models import NetworkDeviceType + net_types = NetworkDeviceType.query.filter(NetworkDeviceType.isactive == True).order_by(NetworkDeviceType.networkdevicetype).all() + subtypes['Network Device'] = [{'id': nt.networkdevicetypeid, 'name': nt.networkdevicetype, 'color': nt.color} for nt in net_types] + except ImportError: + subtypes['Network Device'] = [] + + try: + from plugins.printers.models import PrinterType + printer_types = PrinterType.query.filter(PrinterType.isactive == True).order_by(PrinterType.printertype).all() + subtypes['Printer'] = [{'id': pt.printertypeid, 'name': pt.printertype, 'color': pt.color} for pt in printer_types] + except ImportError: + subtypes['Printer'] = [] + + try: + from plugins.measuringtools.models import MeasuringToolType + measuringtool_types = MeasuringToolType.query.filter( + MeasuringToolType.isactive == True).order_by(MeasuringToolType.name).all() + subtypes['Measuring Tool'] = [{'id': mt.measuringtooltypeid, 'name': mt.name, 'color': mt.color} for mt in measuringtool_types] + except ImportError: + subtypes['Measuring Tool'] = [] + + return success_response({ + 'assets': data, + 'total': len(data), + 'filters': { + 'assettypes': types_data, + 'statuses': status_data, + 'businessunits': bu_data, + 'locations': loc_data, + 'subtypes': subtypes + } + }) + + +@assets_bp.route('//communications', methods=['GET']) +@jwt_required(optional=True) +def get_asset_communications(asset_id: int): + """Get all communications for an asset.""" + from shopdb.core.models import Communication + + asset = db.session.get(Asset, asset_id) + + if not asset: + return error_response( + ErrorCodes.NOT_FOUND, + f'Asset with ID {asset_id} not found', + http_code=404 + ) + + comms = Communication.query.filter_by( + assetid=asset_id, + isactive=True + ).all() + + data = [] + for comm in comms: + c = comm.to_dict() + c['comtype_name'] = comm.comtype.comtype if comm.comtype else None + data.append(c) + + return success_response(data) diff --git a/shopdb/core/api/auditlogs.py b/shopdb/core/api/auditlogs.py new file mode 100644 index 00000000..fca6ec06 --- /dev/null +++ b/shopdb/core/api/auditlogs.py @@ -0,0 +1,196 @@ +"""Audit log API routes.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.core.models import AuditLog +from shopdb.utils.responses import success_response +from shopdb.utils.authz import require_permission + +auditlogs_bp = Blueprint('auditlogs', __name__) + + +def _resolve_full_names(ssos): + """Best-effort SSO -> full name map for audit rows. Mode-aware, guarded, + degrades to {} so the list still renders if the directory is unreachable.""" + wanted = {s for s in ssos if s and str(s).isdigit()} + if not wanted: + return {} + names = {} + # Self-hosted directory first (no external dependency). + try: + from plugins.employees.models import DirectoryEmployee + rows = DirectoryEmployee.query.filter( + DirectoryEmployee.sso.in_(wanted)).all() + for emp in rows: + full = f'{(emp.firstname or "").strip()} {(emp.lastname or "").strip()}'.strip() + if full: + names[str(emp.sso)] = full + except Exception: + pass + missing = wanted - set(names) + if not missing: + return names + # External HR directory for anything still unresolved. + try: + from shopdb.utils.employee_db import employee_connection + conn = employee_connection() + placeholders = ','.join(['%s'] * len(missing)) + with conn.cursor() as cur: + cur.execute( + 'SELECT SSO, First_Name, Last_Name FROM employees ' + f'WHERE SSO IN ({placeholders})', tuple(missing)) + for emp in cur.fetchall(): + full = f'{(emp["First_Name"] or "").strip()} {(emp["Last_Name"] or "").strip()}'.strip() + if full: + names[str(emp['SSO'])] = full + conn.close() + except Exception: + pass + return names + + +@auditlogs_bp.route('', methods=['GET']) +@jwt_required() +@require_permission('audit.view') +def list_auditlogs(): + """ + List audit logs with filtering and pagination. + + Query params: + page: Page number (default 1) + perpage: Items per page (default 50, max 200) + action: Filter by action (created, updated, deleted) + entitytype: Filter by entity type + userid: Filter by user ID + search: Search in entityname or username + from_date: Filter from date (ISO format) + to_date: Filter to date (ISO format) + """ + page = request.args.get('page', 1, type=int) + perpage = min(request.args.get('perpage', 50, type=int), 200) + + query = AuditLog.query + + # Filters + action = request.args.get('action') + if action: + query = query.filter(AuditLog.action == action) + + entitytype = request.args.get('entitytype') + if entitytype: + query = query.filter(AuditLog.entitytype == entitytype) + + userid = request.args.get('userid', type=int) + if userid: + query = query.filter(AuditLog.userid == userid) + + search = request.args.get('search') + if search: + search_term = f'%{search}%' + query = query.filter( + (AuditLog.entityname.ilike(search_term)) | + (AuditLog.username.ilike(search_term)) + ) + + from_date = request.args.get('from_date') + if from_date: + from datetime import datetime + try: + dt = datetime.fromisoformat(from_date.replace('Z', '+00:00')) + query = query.filter(AuditLog.timestamp >= dt) + except ValueError: + pass + + to_date = request.args.get('to_date') + if to_date: + from datetime import datetime + try: + dt = datetime.fromisoformat(to_date.replace('Z', '+00:00')) + query = query.filter(AuditLog.timestamp <= dt) + except ValueError: + pass + + # Order by most recent first + query = query.order_by(AuditLog.timestamp.desc()) + + # Paginate + pagination = query.paginate(page=page, per_page=perpage, error_out=False) + + rows = [log.to_dict() for log in pagination.items] + # Attach full names (hover tooltip on the SSO); best-effort. + fullnames = _resolve_full_names({r.get('username') for r in rows}) + for r in rows: + r['userfullname'] = fullnames.get(str(r.get('username'))) + + return success_response( + rows, + meta={ + 'page': page, + 'perpage': perpage, + 'total': pagination.total, + 'pages': pagination.pages + } + ) + + +@auditlogs_bp.route('/entity//', methods=['GET']) +@jwt_required() +@require_permission('audit.view') +def get_entity_history(entitytype: str, entityid: int): + """Get audit history for a specific entity.""" + logs = AuditLog.query.filter_by( + entitytype=entitytype, + entityid=entityid + ).order_by(AuditLog.timestamp.desc()).all() + + return success_response([log.to_dict() for log in logs]) + + +@auditlogs_bp.route('/stats', methods=['GET']) +@jwt_required() +@require_permission('audit.view') +def get_stats(): + """Get audit log statistics.""" + from sqlalchemy import func + from datetime import datetime, timedelta, timezone + + # Actions by type + actions = db_func_count_by(AuditLog.action) + + # Entity types + entities = db_func_count_by(AuditLog.entitytype) + + # Recent activity (last 7 days) + week_ago = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=7) + recent_count = AuditLog.query.filter(AuditLog.timestamp >= week_ago).count() + + # Most active users (last 7 days) + from shopdb.extensions import db + active_users = db.session.query( + AuditLog.username, + func.count(AuditLog.auditlogid).label('count') + ).filter( + AuditLog.timestamp >= week_ago, + AuditLog.username.isnot(None) + ).group_by(AuditLog.username).order_by(func.count(AuditLog.auditlogid).desc()).limit(5).all() + + return success_response({ + 'actions': actions, + 'entities': entities, + 'recentCount': recent_count, + 'activeUsers': [{'username': u[0], 'count': u[1]} for u in active_users] + }) + + +def db_func_count_by(column): + """Helper to count grouped by a column.""" + from sqlalchemy import func + from shopdb.extensions import db + + results = db.session.query( + column, + func.count().label('count') + ).group_by(column).all() + + return {r[0]: r[1] for r in results} diff --git a/shopdb/core/api/auth.py b/shopdb/core/api/auth.py new file mode 100644 index 00000000..f3d36571 --- /dev/null +++ b/shopdb/core/api/auth.py @@ -0,0 +1,258 @@ +"""Authentication API endpoints.""" + +import time +from datetime import datetime, timedelta, timezone + +from flask import Blueprint, request, current_app +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + jwt_required, + get_jwt_identity, + current_user +) +from werkzeug.security import check_password_hash, generate_password_hash + +from shopdb.extensions import db, cache +from shopdb.core.models import User +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +auth_bp = Blueprint('auth', __name__) + +# Account lockout policy: after MAX_FAILED_LOGINS consecutive bad passwords, +# lock the account for LOCKOUT_MINUTES. Mitigates password brute-forcing. +MAX_FAILED_LOGINS = 5 +LOCKOUT_MINUTES = 15 + + +def _login_ip(): + """Caller IP for rate limiting, honoring the first X-Forwarded-For hop.""" + forwarded = request.headers.get('X-Forwarded-For') + if forwarded: + return forwarded.split(',')[0].strip() + return request.remote_addr or 'unknown' + + +def _login_ratelimited(): + """Fixed-window per-IP login limiter. Returns True when the caller is over + budget for the current window. + + Backed by the existing cache extension (no new dependency). Under the + default SimpleCache the counter is per-process, so with N gunicorn workers + the effective budget is N x AUTH_RATELIMIT_MAX. This is defense in depth + layered on top of the per-account lockout (see login()); a shared cache + backend (Redis/memcached) tightens it to a true global budget. + """ + if not current_app.config.get('AUTH_RATELIMIT_ENABLED', True): + return False + window = current_app.config.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300) + maxhits = current_app.config.get('AUTH_RATELIMIT_MAX', 30) + # Time bucket makes this a fixed window: the key rolls over at each window + # boundary, so a per-hit set() cannot turn it into a sliding window. + bucket = int(time.time() // window) if window > 0 else 0 + key = f'loginratelimit:{_login_ip()}:{bucket}' + count = cache.get(key) or 0 + if count >= maxhits: + return True + cache.set(key, count + 1, timeout=window) + return False + + +@auth_bp.route('/login', methods=['POST']) +def login(): + """ + Authenticate user and return JWT tokens. + + Request: + { + "username": "string", + "password": "string" + } + + Response: + { + "data": { + "access_token": "...", + "refresh_token": "...", + "user": {...} + } + } + """ + if _login_ratelimited(): + return error_response( + 'RATE_LIMITED', + 'Too many login attempts. Try again later.', + http_code=429 + ) + + data = request.get_json() + + if not data or not data.get('username') or not data.get('password'): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Username and password required' + ) + + user = User.query.filter_by( + username=data['username'], + isactive=True + ).first() + + # Reject a locked account before checking the password, so a lockout can't + # be probed and a valid password can't reset the clock mid-lockout. + if user and user.islocked: + return error_response( + ErrorCodes.FORBIDDEN, + 'Account is locked. Try again later or contact an administrator.', + http_code=403 + ) + + if not user or not check_password_hash(user.passwordhash, data['password']): + # Count the failure and lock the account once the threshold is hit. + # Only possible when the username matched a real account. + if user: + user.failedlogins = (user.failedlogins or 0) + 1 + if user.failedlogins >= MAX_FAILED_LOGINS: + # Naive UTC to match the naive lockeduntil column comparisons. + user.lockeduntil = datetime.now(timezone.utc).replace(tzinfo=None) \ + + timedelta(minutes=LOCKOUT_MINUTES) + user.failedlogins = 0 + db.session.commit() + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Invalid username or password', + http_code=401 + ) + + # Create tokens (identity must be a string in Flask-JWT-Extended 4.x) + access_token = create_access_token( + identity=str(user.userid), + additional_claims={ + 'username': user.username, + 'roles': [r.rolename for r in user.roles] + } + ) + refresh_token = create_refresh_token(identity=str(user.userid)) + + # Update last login and clear any failed-login state + user.lastlogindate = db.func.now() + user.failedlogins = 0 + user.lockeduntil = None + db.session.commit() + + return success_response({ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'token_type': 'Bearer', + 'expires_in': 3600, + 'user': { + 'userid': user.userid, + 'username': user.username, + 'email': user.email, + 'firstname': user.firstname, + 'lastname': user.lastname, + 'roles': [r.rolename for r in user.roles], + 'mustchangepassword': bool(user.mustchangepassword) + } + }) + + +@auth_bp.route('/refresh', methods=['POST']) +@jwt_required(refresh=True) +def refresh(): + """Refresh access token using refresh token.""" + user_id = get_jwt_identity() + user = db.session.get(User, int(user_id)) + + if not user or not user.isactive: + return error_response( + ErrorCodes.UNAUTHORIZED, + 'User not found or inactive', + http_code=401 + ) + + access_token = create_access_token( + identity=str(user.userid), + additional_claims={ + 'username': user.username, + 'roles': [r.rolename for r in user.roles] + } + ) + + return success_response({ + 'access_token': access_token, + 'token_type': 'Bearer', + 'expires_in': 3600 + }) + + +@auth_bp.route('/me', methods=['GET']) +@jwt_required() +def get_current_user(): + """Get current authenticated user info.""" + return success_response({ + 'userid': current_user.userid, + 'username': current_user.username, + 'email': current_user.email, + 'firstname': current_user.firstname, + 'lastname': current_user.lastname, + 'roles': [r.rolename for r in current_user.roles], + 'permissions': current_user.getpermissions(), + 'mustchangepassword': bool(current_user.mustchangepassword) + }) + + +@auth_bp.route('/change-password', methods=['POST']) +@jwt_required() +def change_password(): + """Change the authenticated user's own password. + + Request: + { "current_password": "string", "new_password": "string" } + + current_password is required for a normal self-service change. When the + account is flagged mustchangepassword (an admin set a temporary password), + the forced-change case accepts new_password alone. On success the flag is + cleared and any lockout/failed-login state is reset. + """ + data = request.get_json() or {} + new_password = data.get('new_password') + current_password = data.get('current_password') + + if not new_password: + return error_response( + ErrorCodes.VALIDATION_ERROR, 'new_password is required') + if len(new_password) < 8: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'New password must be at least 8 characters') + + user = current_user + + # A normal change must prove knowledge of the current password. The forced + # first-login case (admin-set temp password) may skip it. + if not user.mustchangepassword: + if not current_password: + return error_response( + ErrorCodes.VALIDATION_ERROR, 'current_password is required') + if not check_password_hash(user.passwordhash, current_password): + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Current password is incorrect', http_code=401) + + user.passwordhash = generate_password_hash(new_password) + user.mustchangepassword = False + user.failedlogins = 0 + user.lockeduntil = None + db.session.commit() + + return success_response( + {'mustchangepassword': False}, message='Password changed') + + +@auth_bp.route('/logout', methods=['POST']) +@jwt_required() +def logout(): + """Logout user (for frontend token cleanup).""" + # In a full implementation, you'd blacklist the token + return success_response(message='Successfully logged out') diff --git a/shopdb/core/api/businessunits.py b/shopdb/core/api/businessunits.py new file mode 100644 index 00000000..6baf9b00 --- /dev/null +++ b/shopdb/core/api/businessunits.py @@ -0,0 +1,156 @@ +"""Business Units API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import BusinessUnit +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +businessunits_bp = Blueprint('businessunits', __name__) + + +@businessunits_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_businessunits(): + """List all business units.""" + page, per_page = get_pagination_params(request) + + query = BusinessUnit.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(BusinessUnit.isactive == True) + + # Exact-match natural-key lookup for idempotent import (unit name). + if exactunit := request.args.get('businessunit'): + query = query.filter(BusinessUnit.businessunit == exactunit) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + BusinessUnit.businessunit.ilike(f'%{search}%'), + BusinessUnit.code.ilike(f'%{search}%') + ) + ) + + query = query.order_by(BusinessUnit.businessunit) + + items, total = paginate_query(query, page, per_page) + data = [bu.to_dict() for bu in items] + + return paginated_response(data, page, per_page, total) + + +@businessunits_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_businessunit(bu_id: int): + """Get a single business unit.""" + bu = db.session.get(BusinessUnit, bu_id) + + if not bu: + return error_response( + ErrorCodes.NOT_FOUND, + f'Business unit with ID {bu_id} not found', + http_code=404 + ) + + data = bu.to_dict() + data['parent'] = bu.parent.to_dict() if bu.parent else None + data['children'] = [c.to_dict() for c in bu.children] + + return success_response(data) + + +@businessunits_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_businessunit(): + """Create a new business unit.""" + data = request.get_json() + + if not data or not data.get('businessunit'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunit is required') + + if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Business unit '{data['businessunit']}' already exists", + http_code=409 + ) + + bu = BusinessUnit( + businessunit=data['businessunit'], + code=data.get('code'), + description=data.get('description'), + parentid=data.get('parentid') + ) + + db.session.add(bu) + apply_import_timestamps(bu, data) + db.session.commit() + + return success_response(bu.to_dict(), message='Business unit created', http_code=201) + + +@businessunits_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_businessunit(bu_id: int): + """Update a business unit.""" + bu = db.session.get(BusinessUnit, bu_id) + + if not bu: + return error_response( + ErrorCodes.NOT_FOUND, + f'Business unit with ID {bu_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'businessunit' in data and data['businessunit'] != bu.businessunit: + if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Business unit '{data['businessunit']}' already exists", + http_code=409 + ) + + for key in ['businessunit', 'code', 'description', 'parentid', 'isactive']: + if key in data: + setattr(bu, key, data[key]) + + apply_import_timestamps(bu, data) + db.session.commit() + return success_response(bu.to_dict(), message='Business unit updated') + + +@businessunits_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_businessunit(bu_id: int): + """Delete (deactivate) a business unit.""" + bu = db.session.get(BusinessUnit, bu_id) + + if not bu: + return error_response( + ErrorCodes.NOT_FOUND, + f'Business unit with ID {bu_id} not found', + http_code=404 + ) + + bu.isactive = False + db.session.commit() + + return success_response(message='Business unit deleted') diff --git a/shopdb/core/api/collector.py b/shopdb/core/api/collector.py new file mode 100644 index 00000000..1b0bd654 --- /dev/null +++ b/shopdb/core/api/collector.py @@ -0,0 +1,486 @@ +""" +PowerShell data-collection API endpoints. + +Compatibility layer for the PowerShell scripts that report PC state. Uses an +API key (not JWT) for unattended scripts. Writes the asset/computer model +(ADR-001), not the retired Machine model. +""" + +from datetime import datetime, timezone +from functools import wraps +from flask import Blueprint, request, current_app, g + +from shopdb.extensions import db +from shopdb.core.models import Asset, Application +from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX + +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +collector_bp = Blueprint('collector', __name__) + +# A managed token scoped to this permission is a collector service token: it +# authorizes the collector ingest API and nothing else (ADR-006, machine +# identity - no require_permission on these routes). +COLLECTOR_SCOPE = 'collector.ingest' + + +def _computer_models(): + """Lazily import the computers plugin models. + + The legacy /pc, /apps, /heartbeat, /bulk endpoints predate the generic + collector contract and are computers-specific. Importing the plugin lazily + (instead of at module load) keeps core importable when the computers plugin + is absent or disabled. Returns (Computer, ComputerInstalledApp) or None. + """ + try: + from plugins.computers.models import Computer, ComputerInstalledApp + return Computer, ComputerInstalledApp + except ImportError: + return None + + +def _token_is_collector(token): + """True when this managed token is a collector service token: scoped for + collector.ingest AND its owner actually holds that permission (admins do).""" + from shopdb.core.models import User + + scopelist = token.scopelist + if not scopelist or COLLECTOR_SCOPE not in scopelist: + return False + user = db.session.get(User, token.userid) + return (user is not None and user.isactive + and user.haspermission(COLLECTOR_SCOPE)) + + +def _collector_managed_token(api_key): + """Find the managed token backing this collector request, or None. + + Two transports are accepted (GE-Enforce sends X-API-Key today; Bearer is + the standard PAT wire form): + X-API-Key: the before_request PAT shim ignores this header, so resolve the + secret here. + Bearer: the shim already resolved+swapped it (the raw Bearer secret is + gone by now), leaving g.apitokenid. Reuse that. + """ + from shopdb.utils.apitoken_auth import resolve_api_token + + if api_key and api_key.startswith(TOKEN_SECRET_PREFIX): + resolved = resolve_api_token(api_key) + return resolved[0] if resolved else None + tokenid = getattr(g, 'apitokenid', None) + if tokenid is not None: + return db.session.get(ApiToken, tokenid) + return None + + +def _check_collector_auth(expected_key): + """Authorize a collector request. Returns None when authorized, else the + error_response to return. + + Accepts a collector-scoped managed token (Bearer or X-API-Key) OR the env + key via X-API-Key (legacy/bootstrap fallback). Env keys stay supported so + nothing breaks; a valid managed token works even with no env key set. + """ + from shopdb.utils.apitoken_auth import touch_apitoken_lastused + + api_key = request.headers.get('X-API-Key') + + token = _collector_managed_token(api_key) + if token is not None and _token_is_collector(token): + touch_apitoken_lastused(token) + return None + + if expected_key and api_key == expected_key: + return None + + # Fail-closed only on pure server misconfiguration: no env key AND the + # caller presented no managed token at all. Otherwise it is a bad credential. + presented_pat = token is not None or ( + api_key is not None and api_key.startswith(TOKEN_SECRET_PREFIX)) + if not expected_key and not presented_pat: + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Collector API key not configured', http_code=500) + return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', + http_code=401) + + +def require_api_key(f): + """Require collector API-key OR collector-scoped managed-token auth.""" + @wraps(f) + def decorated(*args, **kwargs): + # Header only. Querystring api_key was dropped so keys do not land in + # access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md). + error = _check_collector_auth(current_app.config.get('COLLECTOR_API_KEY')) + if error is not None: + return error + return f(*args, **kwargs) + return decorated + + +def _find_pc(hostname): + """Find a computer by hostname, falling back to its asset number. + + Returns None if not found or if the computers plugin is unavailable; + callers already treat None as a 404. + """ + models = _computer_models() + if not models: + return None + Computer, _ = models + comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first() + if comp: + return comp + return ( + Computer.query.join(Asset, Asset.assetid == Computer.assetid) + .filter(Asset.assetnumber.ilike(hostname)) + .first() + ) + + +def _parse_boot(value): + try: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + except (ValueError, AttributeError): + return None + + +def _plugin_api_key(pluginname): + """Per-plugin collector key, falling back to the shared key (ADR-006). + + Looks up COLLECTOR_API_KEY_ (uppercased) first so each + collector can carry its own credential, then COLLECTOR_API_KEY. + """ + per_plugin = current_app.config.get( + f'COLLECTOR_API_KEY_{pluginname.upper()}') + return per_plugin or current_app.config.get('COLLECTOR_API_KEY') + + +def _collector_plugins(): + """Map of pluginname -> (plugin, schema) for plugins accepting collector input.""" + pm = current_app.extensions.get('plugin_manager') + result = {} + if not pm: + return result + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + schema = plugin.get_collector_schema() + except Exception: + # Fail loud in dev/test so a broken hook is visible; isolate the + # misbehaving plugin in prod and keep serving the healthy ones. + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_collector_schema failed', name) + continue + if schema: + result[name] = (plugin, schema) + return result + + +@collector_bp.route('/', methods=['POST']) +def generic_collect(pluginname): + """Generic collector ingest for any plugin (ADR-006). + + Per-plugin API key auth, schema-driven validation of the identity field, + idempotent upsert via the plugin's apply_collector_payload. Returns the + ADR-006 response contract: status, action, assetid, identityvalue, warnings. + """ + from shopdb.core.models import AuditLog + + plugins = _collector_plugins() + if pluginname not in plugins: + return error_response( + ErrorCodes.NOT_FOUND, + f'No collector registered for plugin {pluginname}', + http_code=404) + + plugin, schema = plugins[pluginname] + + # Per-plugin env key (fallback) OR a collector-scoped managed token. Header + # only (querystring fallback dropped, see require_api_key). + error = _check_collector_auth(_plugin_api_key(pluginname)) + if error is not None: + return error + + payload = request.get_json(silent=True) + if not payload or not isinstance(payload, dict): + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + identityfield = schema.get('identityfield') + identityvalue = (payload.get(identityfield) or '').strip() if identityfield else '' + if identityfield and not identityvalue: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'{identityfield} is required') + + try: + outcome = plugin.apply_collector_payload(payload) + except NotImplementedError: + return error_response( + ErrorCodes.INTERNAL_ERROR, + f'Plugin {pluginname} does not implement apply_collector_payload', + http_code=500) + except ValueError as exc: + # ValueError is the plugin's controlled validation signal; its message + # is safe to return to the caller. + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc)) + except Exception: + # Do not leak internals (stack detail, DB errors) to the caller; log it. + db.session.rollback() + current_app.logger.exception('Collector upsert failed for %s', pluginname) + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Internal error processing collector payload', + http_code=500) + + action = outcome.get('action', 'noop') + AuditLog.log( + action if action in ('created', 'updated') else 'updated', + 'Collector', + entityid=outcome.get('assetid'), + entityname=outcome.get('identityvalue', identityvalue), + details=f'collector:{pluginname} action={action}', + ) + db.session.commit() + + response = { + 'status': 'ok', + 'action': action, + 'assetid': outcome.get('assetid'), + 'identityvalue': outcome.get('identityvalue', identityvalue), + 'warnings': outcome.get('warnings', []), + } + # Generic passthrough: a plugin may surface extra result fields (e.g. the + # computers plugin returns printerlinks). Known keys win over extras. + for key, value in (outcome.get('extra') or {}).items(): + response.setdefault(key, value) + return success_response(response, message=f'{pluginname} collector {action}') + + +@collector_bp.route('/_schemas', methods=['GET']) +def collector_schemas(): + """List collector schemas for all enabled plugins (JWT-protected).""" + from flask_jwt_extended import verify_jwt_in_request + verify_jwt_in_request() + + schemas = { + name: schema for name, (plugin, schema) in _collector_plugins().items() + } + return success_response({'schemas': schemas}) + + +@collector_bp.route('/pc', methods=['POST']) +@require_api_key +def update_pc_info(): + """Update one PC from the collection script (matched by hostname).""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostname = data.get('hostname') + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required') + + comp = _find_pc(hostname) + if not comp: + return error_response(ErrorCodes.NOT_FOUND, + f'PC with hostname {hostname} not found', + http_code=404) + + comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None) + + if data.get('lastboottime'): + boot = _parse_boot(data['lastboottime']) + if boot: + comp.lastboottime = boot + if data.get('currentuser'): + comp.loggedinuser = data['currentuser'] + if data.get('serialnumber') and comp.asset: + comp.asset.serialnumber = data['serialnumber'] + + db.session.commit() + + return success_response({ + 'computerid': comp.computerid, + 'hostname': comp.hostname, + 'updated': True + }, message='PC info updated') + + +@collector_bp.route('/apps', methods=['POST']) +@require_api_key +def update_installed_apps(): + """Update installed applications for a PC (matched by hostname).""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostname = data.get('hostname') + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required') + + apps = data.get('apps', []) + if not apps: + return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required') + + comp = _find_pc(hostname) + if not comp: + return error_response(ErrorCodes.NOT_FOUND, + f'PC with hostname {hostname} not found', + http_code=404) + + # comp existing implies the computers plugin is loaded. + _, ComputerInstalledApp = _computer_models() + + updated_count = created_count = skipped_count = 0 + + for app_data in apps: + app_name = app_data.get('appname') + if not app_name: + skipped_count += 1 + continue + + app = Application.query.filter(Application.appname.ilike(app_name)).first() + if not app: + # Only track applications we manage + skipped_count += 1 + continue + + version = app_data.get('version') + installed = ComputerInstalledApp.query.filter_by( + computerid=comp.computerid, appid=app.appid).first() + + if installed: + changed = False + if version and installed.installedversion != version: + installed.installedversion = version + changed = True + if not installed.isactive: + installed.isactive = True + changed = True + if changed: + updated_count += 1 + else: + db.session.add(ComputerInstalledApp( + computerid=comp.computerid, + appid=app.appid, + installedversion=version, + )) + created_count += 1 + + db.session.commit() + + return success_response({ + 'hostname': hostname, + 'computerid': comp.computerid, + 'created': created_count, + 'updated': updated_count, + 'skipped': skipped_count + }, message='Installed apps updated') + + +@collector_bp.route('/heartbeat', methods=['POST']) +@require_api_key +def pc_heartbeat(): + """Record PC online status / heartbeat (single or batch).""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostnames = data.get('hostnames', []) + if not hostnames and data.get('hostname'): + hostnames = [data['hostname']] + if not hostnames: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname or hostnames required') + + updated = 0 + not_found = [] + now = datetime.now(timezone.utc).replace(tzinfo=None) + for hostname in hostnames: + comp = _find_pc(hostname) + if comp: + comp.lastreporteddate = now + updated += 1 + else: + not_found.append(hostname) + + db.session.commit() + + return success_response({ + 'updated': updated, + 'notfound': not_found, + 'timestamp': now.isoformat() + }, message=f'{updated} PC(s) heartbeat recorded') + + +@collector_bp.route('/bulk', methods=['POST']) +@require_api_key +def bulk_update(): + """Bulk update multiple PCs at once.""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + pcs = data.get('pcs', []) + if not pcs: + return error_response(ErrorCodes.VALIDATION_ERROR, 'pcs list is required') + + updated = 0 + not_found = [] + errors = [] + now = datetime.now(timezone.utc).replace(tzinfo=None) + + for pc_data in pcs: + hostname = pc_data.get('hostname') + if not hostname: + continue + + comp = _find_pc(hostname) + if not comp: + not_found.append(hostname) + continue + + try: + comp.lastreporteddate = now + if pc_data.get('currentuser'): + comp.loggedinuser = pc_data['currentuser'] + if pc_data.get('lastboottime'): + boot = _parse_boot(pc_data['lastboottime']) + if boot: + comp.lastboottime = boot + updated += 1 + except Exception: + # Keep the hostname so the caller knows which PC failed, but do not + # leak the exception detail; log it server-side. + current_app.logger.exception('Bulk update failed for %s', hostname) + errors.append({'hostname': hostname, 'error': 'processing failed'}) + + db.session.commit() + + return success_response({ + 'updated': updated, + 'notfound': not_found, + 'errors': errors, + 'timestamp': now.isoformat() + }, message=f'{updated} PC(s) updated') + + +@collector_bp.route('/status', methods=['GET']) +@require_api_key +def collector_status(): + """Check collector API status.""" + return success_response({ + 'status': 'ok', + 'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'endpoints': [ + 'POST /api/collector/', + 'GET /api/collector/_schemas', + 'POST /api/collector/pc', + 'POST /api/collector/apps', + 'POST /api/collector/heartbeat', + 'POST /api/collector/bulk', + 'GET /api/collector/status' + ] + }) diff --git a/shopdb/core/api/customfields.py b/shopdb/core/api/customfields.py new file mode 100644 index 00000000..3032bf0e --- /dev/null +++ b/shopdb/core/api/customfields.py @@ -0,0 +1,218 @@ +"""Custom fields API - definitions per asset type + per-asset values.""" + +import json +import re + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import CustomField, CustomFieldValue, Asset, AssetType +from shopdb.core.models.customfield import CUSTOM_FIELD_DATATYPES +from shopdb.utils.responses import ( + success_response, + error_response, + ErrorCodes +) +from shopdb.utils.authz import require_role + +customfields_bp = Blueprint('customfields', __name__) + + +def _slugify_key(label): + """Build a stable machine key from a label: lowercase, alnum only.""" + key = re.sub(r'[^a-z0-9]', '', (label or '').lower()) + return key[:50] or 'field' + + +def _normalize_options(raw): + """Return a JSON string of a clean list of option strings, or None.""" + if not raw: + return None + if isinstance(raw, str): + # Accept newline- or comma-separated text too. + parts = re.split(r'[\n,]', raw) + elif isinstance(raw, (list, tuple)): + parts = raw + else: + return None + cleaned = [str(p).strip() for p in parts if str(p).strip()] + return json.dumps(cleaned) if cleaned else None + + +# ============================================================================= +# Field definitions +# ============================================================================= + +@customfields_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_fields(): + """List custom-field definitions. Filter by ?assettypeid=. ?active=false + includes inactive ones.""" + query = CustomField.query + assettypeid = request.args.get('assettypeid', type=int) + if assettypeid: + query = query.filter_by(assettypeid=assettypeid) + if request.args.get('active', 'true').lower() != 'false': + query = query.filter_by(isactive=True) + fields = query.order_by(CustomField.sortorder, CustomField.fieldid).all() + return success_response([f.to_dict() for f in fields]) + + +@customfields_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_field(): + data = request.get_json() or {} + assettypeid = data.get('assettypeid') + label = (data.get('label') or '').strip() + if not assettypeid or not label: + return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid and label are required') + if not db.session.get(AssetType, assettypeid): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown assettypeid') + + datatype = data.get('datatype') or 'text' + if datatype not in CUSTOM_FIELD_DATATYPES: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'datatype must be one of {", ".join(CUSTOM_FIELD_DATATYPES)}') + + fieldkey = (data.get('fieldkey') or '').strip() or _slugify_key(label) + # Reactivate a same-key field that was soft-deleted, instead of colliding. + existing = CustomField.query.filter_by(assettypeid=assettypeid, fieldkey=fieldkey).first() + if existing: + if not existing.isactive: + existing.isactive = True + existing.label = label + existing.datatype = datatype + existing.options = _normalize_options(data.get('options')) + existing.showondetail = bool(data.get('showondetail', True)) + existing.showonform = bool(data.get('showonform', True)) + existing.searchable = bool(data.get('searchable', False)) + existing.sortorder = data.get('sortorder', 0) + db.session.commit() + return success_response(existing.to_dict(), message='Reactivated existing field') + return error_response(ErrorCodes.CONFLICT, + f'A field with key "{fieldkey}" already exists for this asset type') + + field = CustomField( + assettypeid=assettypeid, + fieldkey=fieldkey, + label=label, + datatype=datatype, + options=_normalize_options(data.get('options')), + showondetail=bool(data.get('showondetail', True)), + showonform=bool(data.get('showonform', True)), + searchable=bool(data.get('searchable', False)), + sortorder=data.get('sortorder', 0), + ) + db.session.add(field) + db.session.commit() + return success_response(field.to_dict(), message='Custom field created', http_code=201) + + +@customfields_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_field(fieldid): + field = db.session.get(CustomField, fieldid) + if not field: + return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404) + data = request.get_json() or {} + if 'label' in data: + field.label = (data['label'] or '').strip() or field.label + if 'datatype' in data: + if data['datatype'] not in CUSTOM_FIELD_DATATYPES: + return error_response(ErrorCodes.VALIDATION_ERROR, 'invalid datatype') + field.datatype = data['datatype'] + if 'options' in data: + field.options = _normalize_options(data['options']) + for key in ('showondetail', 'showonform', 'isactive', 'searchable'): + if key in data: + setattr(field, key, bool(data[key])) + if 'sortorder' in data: + field.sortorder = data['sortorder'] + db.session.commit() + return success_response(field.to_dict(), message='Custom field updated') + + +@customfields_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_field(fieldid): + field = db.session.get(CustomField, fieldid) + if not field: + return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404) + # Drop the field and any stored values for it. + CustomFieldValue.query.filter_by(fieldid=fieldid).delete() + db.session.delete(field) + db.session.commit() + return success_response(message='Custom field deleted') + + +# ============================================================================= +# Per-asset values +# ============================================================================= + +def _fields_with_values(asset): + """Return active field defs for this asset's type, each with the asset's + stored value merged in.""" + fields = (CustomField.query + .filter_by(assettypeid=asset.assettypeid, isactive=True) + .order_by(CustomField.sortorder, CustomField.fieldid) + .all()) + values = {v.fieldid: v.value for v in + CustomFieldValue.query.filter_by(assetid=asset.assetid).all()} + result = [] + for f in fields: + item = f.to_dict() + item['value'] = values.get(f.fieldid) + result.append(item) + return result + + +@customfields_bp.route('/asset/', methods=['GET']) +@jwt_required(optional=True) +def get_asset_fields(assetid): + """Active custom fields for an asset's type, merged with its values.""" + asset = db.session.get(Asset, assetid) + if not asset: + return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404) + return success_response(_fields_with_values(asset)) + + +@customfields_bp.route('/asset/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def save_asset_fields(assetid): + """Upsert values for an asset. Body: {values: {fieldid: value, ...}}.""" + asset = db.session.get(Asset, assetid) + if not asset: + return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404) + data = request.get_json() or {} + values = data.get('values') or {} + + # Only accept fields that belong to this asset's type. + valid_ids = {f.fieldid for f in + CustomField.query.filter_by(assettypeid=asset.assettypeid).all()} + + for raw_fieldid, raw_value in values.items(): + try: + fieldid = int(raw_fieldid) + except (ValueError, TypeError): + continue + if fieldid not in valid_ids: + continue + value = '' if raw_value is None else str(raw_value) + row = CustomFieldValue.query.filter_by(fieldid=fieldid, assetid=assetid).first() + if value == '': + # Empty clears the stored value. + if row: + db.session.delete(row) + continue + if row: + row.value = value + else: + db.session.add(CustomFieldValue(fieldid=fieldid, assetid=assetid, value=value)) + + db.session.commit() + return success_response(_fields_with_values(asset), message='Custom fields saved') diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py new file mode 100644 index 00000000..44bc2dd6 --- /dev/null +++ b/shopdb/core/api/dashboard.py @@ -0,0 +1,216 @@ +"""Dashboard API endpoints.""" + +from flask import Blueprint, current_app +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType, AssetStatus +from shopdb.utils.responses import success_response + +dashboard_bp = Blueprint('dashboard', __name__) + +# Map asset type name -> dashboard category label +_TYPE_CATEGORY = { + 'machine': 'Machine', + 'computer': 'PC', + 'printer': 'Printer', + 'network_device': 'Network', + 'measuring_tool': 'Measuring Tool', +} + + +def _count_by_type(assettype): + query = db.session.query(Asset).join(AssetType).filter( + Asset.isactive == True, + AssetType.assettype == assettype + ) + # Dualpath single-machine collapse: subtract the hidden secondary bays so a + # dual-bay machine counts once. Only machines are ever Dualpath-paired, but + # the AssetType filter above keeps this correct for any type. Default on. + if assettype == 'machine': + from shopdb.core.services.dualpath import ( + resolve_dualpath_pairs, dualpath_single_machine_enabled) + if dualpath_single_machine_enabled(): + secondaryassetids = resolve_dualpath_pairs().secondaryassetids + if secondaryassetids: + query = query.filter(Asset.assetid.notin_(secondaryassetids)) + return query.count() + + +@dashboard_bp.route('/summary', methods=['GET']) +@dashboard_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def get_dashboard(): + """Get dashboard summary data (asset-based).""" + machine_count = _count_by_type('machine') + pc_count = _count_by_type('computer') + network_count = _count_by_type('network_device') + printer_count = _count_by_type('printer') + measuringtool_count = _count_by_type('measuring_tool') + total = machine_count + pc_count + network_count + printer_count + measuringtool_count + + # Count by status + status_counts = db.session.query( + AssetStatus.status, + db.func.count(Asset.assetid) + ).outerjoin( + Asset, + db.and_(Asset.statusid == AssetStatus.statusid, Asset.isactive == True) + ).group_by(AssetStatus.status).all() + status_dict = {status: count for status, count in status_counts} + + # Recent assets + recent = Asset.query.filter_by(isactive=True).order_by( + Asset.createddate.desc() + ).limit(10).all() + + return success_response({ + # Fields expected by frontend (totalmachines now means machines, + # totalassets is the grand total - renamed with the machines plugin) + 'totalassets': total, + 'totalmachines': machine_count, + 'totalpc': pc_count, + 'totalnetwork': network_count, + 'totalprinter': printer_count, + 'totalmeasuringtool': measuringtool_count, + 'activeassets': status_dict.get('In Use', 0), + 'inrepair': status_dict.get('In Repair', 0), + # Structured data + 'counts': { + 'machines': machine_count, + 'pcs': pc_count, + 'networkdevices': network_count, + 'printers': printer_count, + 'measuringtools': measuringtool_count, + 'total': total + }, + 'bystatus': status_dict, + 'recent': [ + { + 'assetid': a.assetid, + 'assetnumber': a.assetnumber, + 'assettype': a.assettype.assettype if a.assettype else None, + 'createddate': a.createddate.isoformat() + 'Z' if a.createddate else None + } + for a in recent + ] + }) + + +@dashboard_bp.route('/stats', methods=['GET']) +@jwt_required(optional=True) +def get_stats(): + """Get detailed statistics by asset type.""" + type_counts = db.session.query( + AssetType.assettype, + db.func.count(Asset.assetid) + ).outerjoin( + Asset, + db.and_(Asset.assettypeid == AssetType.assettypeid, Asset.isactive == True) + ).group_by(AssetType.assettypeid).all() + + return success_response({ + 'bytype': [ + { + 'type': t, + 'category': _TYPE_CATEGORY.get(t, t), + 'count': count + } + for t, count in type_counts + ] + }) + + +@dashboard_bp.route('/navigation', methods=['GET']) +def get_navigation(): + """Get navigation items from all loaded plugins.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + return success_response([]) + + all_items = [] + + # Core navigation items (always present) + all_items.extend([ + {'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/', 'position': 0}, + {'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4}, + ]) + + # Collect navigation items from enabled plugins. Disabling persists to the + # registry immediately, so a disabled plugin drops out of the menu right + # away (its routes stay registered until the next restart - Flask cannot + # unregister a blueprint at runtime). + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + items = plugin.get_navigation_items() + for item in items: + item['plugin'] = name + all_items.extend(items) + except Exception: + # Fail loud in dev/test; isolate a broken plugin in prod. + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_navigation_items failed', name) + + # Add core information section items + all_items.extend([ + {'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'}, + {'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'}, + ]) + + # Sort by position + all_items.sort(key=lambda x: x.get('position', 99)) + + return success_response(all_items) + + +@dashboard_bp.route('/widgets', methods=['GET']) +@jwt_required(optional=True) +def get_widgets(): + """Aggregate dashboard widget definitions from all enabled plugins. + + Consumer for the BasePlugin.get_dashboard_widgets hook. Skips disabled + plugins, isolates a broken plugin in prod (re-raises in dev/test), and + returns the merged list sorted by position. + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + return success_response([]) + + widgets = [] + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + for widget in plugin.get_dashboard_widgets() or []: + widget['plugin'] = name + widgets.append(widget) + except Exception: + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_dashboard_widgets failed', name) + + widgets.sort(key=lambda w: w.get('position', 99)) + return success_response(widgets) + + +@dashboard_bp.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint (no auth required).""" + from shopdb import __version__ + + try: + db.session.execute(db.text('SELECT 1')) + db_status = 'healthy' + except Exception as e: + db_status = f'unhealthy: {str(e)}' + + return success_response({ + 'status': 'ok' if db_status == 'healthy' else 'degraded', + 'database': db_status, + 'version': __version__ + }) diff --git a/shopdb/core/api/dashboarddefaults.py b/shopdb/core/api/dashboarddefaults.py new file mode 100644 index 00000000..23fdc6b0 --- /dev/null +++ b/shopdb/core/api/dashboarddefaults.py @@ -0,0 +1,131 @@ +"""Dashboard defaults API: visitor-IP -> business-unit mapping. + +CRUD for the mappings plus a resolve endpoint a kiosk/lobby dashboard calls to +auto-select its business unit from the display PC's IP. +""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import DashboardDefault, BusinessUnit, AuditLog +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_role + +dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__) + + +def _request_ip(): + """Caller IP, honoring a single proxy hop via X-Forwarded-For.""" + forwarded = request.headers.get('X-Forwarded-For') + if forwarded: + return forwarded.split(',')[0].strip() + return request.remote_addr + + +def _serialize(default): + data = default.to_dict() + data['businessunit'] = default.businessunit.businessunit \ + if default.businessunit else None + return data + + +@dashboarddefaults_bp.route('/visitor-location', methods=['GET']) +def visitor_location(): + """Resolve the business unit for the calling display by its IP. + + Unauthenticated: kiosks/lobby displays hit this. Returns the mapped + business unit, or a null businessunitid when the IP is not mapped. + """ + ipaddress = request.args.get('ipaddress') or _request_ip() + default = DashboardDefault.query.filter_by( + ipaddress=ipaddress, isactive=True).first() + if not default: + return success_response({ + 'ipaddress': ipaddress, + 'businessunitid': None, + 'businessunit': None, + }) + return success_response({ + 'ipaddress': ipaddress, + 'businessunitid': default.businessunitid, + 'businessunit': default.businessunit.businessunit + if default.businessunit else None, + }) + + +@dashboarddefaults_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_defaults(): + """List all visitor-IP -> business-unit mappings.""" + defaults = DashboardDefault.query.filter_by(isactive=True).order_by( + DashboardDefault.ipaddress).all() + return success_response([_serialize(d) for d in defaults]) + + +@dashboarddefaults_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_default(): + """Create a visitor-IP -> business-unit mapping.""" + data = request.get_json() or {} + ipaddress = (data.get('ipaddress') or '').strip() + businessunitid = data.get('businessunitid') + + if not ipaddress: + return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required') + if not businessunitid: + return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunitid is required') + if not db.session.get(BusinessUnit, businessunitid): + return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found', + http_code=404) + if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first(): + return error_response(ErrorCodes.CONFLICT, + f"IP {ipaddress} is already mapped", http_code=409) + + default = DashboardDefault(ipaddress=ipaddress, businessunitid=businessunitid, + description=data.get('description')) + db.session.add(default) + db.session.flush() + AuditLog.log('created', 'DashboardDefault', entityid=default.dashboarddefaultid, + entityname=ipaddress) + db.session.commit() + return success_response(_serialize(default), message='Mapping created', + http_code=201) + + +@dashboarddefaults_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_default(default_id): + """Update a mapping.""" + default = db.session.get(DashboardDefault, default_id) + if not default: + return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404) + + data = request.get_json() or {} + if 'businessunitid' in data: + if not db.session.get(BusinessUnit, data['businessunitid']): + return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found', + http_code=404) + default.businessunitid = data['businessunitid'] + if 'ipaddress' in data and data['ipaddress']: + default.ipaddress = data['ipaddress'].strip() + if 'description' in data: + default.description = data['description'] + + db.session.commit() + return success_response(_serialize(default), message='Mapping updated') + + +@dashboarddefaults_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_default(default_id): + """Delete (deactivate) a mapping.""" + default = db.session.get(DashboardDefault, default_id) + if not default: + return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404) + default.isactive = False + db.session.commit() + return success_response(message='Mapping deleted') diff --git a/shopdb/core/api/locations.py b/shopdb/core/api/locations.py new file mode 100644 index 00000000..dd4cf56b --- /dev/null +++ b/shopdb/core/api/locations.py @@ -0,0 +1,241 @@ +"""Locations API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Location, LocationType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +locations_bp = Blueprint('locations', __name__) + + +def _loc_type_dict(t): + return { + 'locationtypeid': t.locationtypeid, + 'locationtype': t.locationtype, + 'description': t.description, + 'color': t.color, + 'isactive': t.isactive, + } + + +@locations_bp.route('/types', methods=['GET']) +@jwt_required(optional=True) +def list_location_types(): + """List location types. ?active=false includes inactive ones.""" + query = LocationType.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter_by(isactive=True) + types = query.order_by(LocationType.locationtype).all() + return success_response([_loc_type_dict(t) for t in types]) + + +@locations_bp.route('/types', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_location_type(): + data = request.get_json() or {} + if not data.get('locationtype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'locationtype is required') + existing = LocationType.query.filter_by(locationtype=data['locationtype']).first() + if existing: + if not existing.isactive: + existing.isactive = True + for key in ('description', 'color'): + if data.get(key) is not None: + setattr(existing, key, data[key]) + db.session.commit() + return success_response(_loc_type_dict(existing), message='Reactivated existing type') + return error_response(ErrorCodes.CONFLICT, + f"Location type '{data['locationtype']}' already exists", http_code=409) + t = LocationType(locationtype=data['locationtype'], + description=data.get('description'), color=data.get('color')) + db.session.add(t) + db.session.commit() + return success_response(_loc_type_dict(t), message='Location type created', http_code=201) + + +@locations_bp.route('/types/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_location_type(type_id): + t = db.session.get(LocationType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404) + data = request.get_json() or {} + if 'locationtype' in data and data['locationtype'] != t.locationtype: + if LocationType.query.filter_by(locationtype=data['locationtype']).first(): + return error_response(ErrorCodes.CONFLICT, + f"Location type '{data['locationtype']}' already exists", http_code=409) + for key in ('locationtype', 'description', 'color', 'isactive'): + if key in data: + setattr(t, key, data[key]) + db.session.commit() + return success_response(_loc_type_dict(t), message='Location type updated') + + +@locations_bp.route('/types/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_location_type(type_id): + t = db.session.get(LocationType, type_id) + if not t: + return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404) + inuse = Location.query.filter_by(locationtypeid=type_id).count() + if inuse: + return error_response(ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} location(s) still use this type", http_code=409) + db.session.delete(t) + db.session.commit() + return success_response(message='Location type deleted') + + +@locations_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_locations(): + """List all locations.""" + page, per_page = get_pagination_params(request) + + query = Location.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Location.isactive == True) + + # Exact-match natural-key lookup for idempotent import (location name). + if exactname := request.args.get('locationname'): + query = query.filter(Location.locationname == exactname) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + Location.locationname.ilike(f'%{search}%'), + Location.building.ilike(f'%{search}%') + ) + ) + + query = query.order_by(Location.locationname) + + items, total = paginate_query(query, page, per_page) + data = [loc.to_dict() for loc in items] + + return paginated_response(data, page, per_page, total) + + +@locations_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_location(location_id: int): + """Get a single location.""" + loc = db.session.get(Location, location_id) + + if not loc: + return error_response( + ErrorCodes.NOT_FOUND, + f'Location with ID {location_id} not found', + http_code=404 + ) + + return success_response(loc.to_dict()) + + +@locations_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_location(): + """Create a new location.""" + data = request.get_json() + + if not data or not data.get('locationname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'locationname is required') + + if Location.query.filter_by(locationname=data['locationname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Location '{data['locationname']}' already exists", + http_code=409 + ) + + loc = Location( + locationname=data['locationname'], + building=data.get('building'), + floor=data.get('floor'), + room=data.get('room'), + description=data.get('description'), + locationtypeid=data.get('locationtypeid'), + parentlocationid=data.get('parentlocationid'), + mapimage=data.get('mapimage'), + mapwidth=data.get('mapwidth'), + mapheight=data.get('mapheight') + ) + + db.session.add(loc) + apply_import_timestamps(loc, data) + db.session.commit() + + return success_response(loc.to_dict(), message='Location created', http_code=201) + + +@locations_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_location(location_id: int): + """Update a location.""" + loc = db.session.get(Location, location_id) + + if not loc: + return error_response( + ErrorCodes.NOT_FOUND, + f'Location with ID {location_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'locationname' in data and data['locationname'] != loc.locationname: + if Location.query.filter_by(locationname=data['locationname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Location '{data['locationname']}' already exists", + http_code=409 + ) + + for key in ['locationname', 'building', 'floor', 'room', 'description', + 'locationtypeid', 'parentlocationid', 'mapimage', 'mapwidth', + 'mapheight', 'isactive']: + if key in data: + setattr(loc, key, data[key]) + + apply_import_timestamps(loc, data) + db.session.commit() + return success_response(loc.to_dict(), message='Location updated') + + +@locations_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_location(location_id: int): + """Delete (deactivate) a location.""" + loc = db.session.get(Location, location_id) + + if not loc: + return error_response( + ErrorCodes.NOT_FOUND, + f'Location with ID {location_id} not found', + http_code=404 + ) + + loc.isactive = False + db.session.commit() + + return success_response(message='Location deleted') diff --git a/shopdb/core/api/models.py b/shopdb/core/api/models.py new file mode 100644 index 00000000..e3688bd4 --- /dev/null +++ b/shopdb/core/api/models.py @@ -0,0 +1,262 @@ +"""Models (vendor model catalog) API endpoints - Full CRUD.""" + +import glob +import os + +from flask import Blueprint, request, current_app, send_from_directory +from flask_jwt_extended import jwt_required +from werkzeug.utils import secure_filename + +from shopdb.extensions import db +from shopdb.core.models import Model +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +models_bp = Blueprint('models', __name__) + +# Uploaded model photos live in the instance dir and are served publicly +# (asset detail pages read the model image without auth). Same image set the +# map/branding uploads accept. +MODEL_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} + +# URL prefix an uploaded image resolves to. imageurl values with this prefix +# are our own files under the instance dir; anything else (external URLs or the +# shipped /images/models/* assets) is left on disk untouched. +MODEL_IMAGE_URL_PREFIX = '/api/models/image/' + + +def _modelimage_dir(): + return os.path.join(current_app.instance_path, 'modelimages') + + +@models_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_models(): + """List all vendor catalog models.""" + page, per_page = get_pagination_params(request) + + query = Model.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Model.isactive == True) + + if vendor_id := request.args.get('vendor', type=int): + query = query.filter(Model.vendorid == vendor_id) + + if modeltype_id := request.args.get('modeltype', type=int): + query = query.filter(Model.modeltypeid == modeltype_id) + + # Exact-match natural-key lookup for idempotent import. Natural key is + # modelnumber + vendor; pair this with ?vendor= to disambiguate. + if exactmodelnumber := request.args.get('modelnumber'): + query = query.filter(Model.modelnumber == exactmodelnumber) + + if search := request.args.get('search'): + query = query.filter(Model.modelnumber.ilike(f'%{search}%')) + + query = query.order_by(Model.modelnumber) + + items, total = paginate_query(query, page, per_page) + + data = [] + for m in items: + d = m.to_dict() + d['vendor'] = m.vendor.vendor if m.vendor else None + d['modeltype'] = m.modeltype.modeltype if m.modeltype else None + data.append(d) + + return paginated_response(data, page, per_page, total) + + +@models_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_model(model_id: int): + """Get a single model.""" + m = db.session.get(Model, model_id) + + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + data = m.to_dict() + data['vendor'] = m.vendor.to_dict() if m.vendor else None + data['modeltype'] = m.modeltype.to_dict() if m.modeltype else None + + return success_response(data) + + +@models_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_model(): + """Create a new model.""" + data = request.get_json() + + if not data or not data.get('modelnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumber is required') + + # Check duplicate + existing = Model.query.filter_by( + modelnumber=data['modelnumber'], + vendorid=data.get('vendorid') + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Model '{data['modelnumber']}' already exists for this vendor", + http_code=409 + ) + + m = Model( + modelnumber=data['modelnumber'], + vendorid=data.get('vendorid'), + modeltypeid=data.get('modeltypeid'), + description=data.get('description'), + imageurl=data.get('imageurl'), + documentationurl=data.get('documentationurl'), + notes=data.get('notes') + ) + + db.session.add(m) + apply_import_timestamps(m, data) + db.session.commit() + + return success_response(m.to_dict(), message='Model created', http_code=201) + + +@models_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_model(model_id: int): + """Update a model.""" + m = db.session.get(Model, model_id) + + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']: + if key in data: + setattr(m, key, data[key]) + + apply_import_timestamps(m, data) + db.session.commit() + return success_response(m.to_dict(), message='Model updated') + + +@models_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_model(model_id: int): + """Delete (deactivate) a model.""" + m = db.session.get(Model, model_id) + + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + m.isactive = False + db.session.commit() + + return success_response(message='Model deleted') + + +@models_bp.route('//image', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_model_image(model_id: int): + """Upload (or replace) the photo for a model. + + multipart/form-data: file=. Saves to the instance modelimages dir as + model- (one image per model) and points model.imageurl at the + served URL. Re-upload replaces the old file even when the extension changes. + """ + m = db.session.get(Model, model_id) + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in MODEL_IMAGE_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + imagedir = _modelimage_dir() + os.makedirs(imagedir, exist_ok=True) + + # Wipe any prior model-.* file so a new extension does not orphan the old + # one (one image per model). + for old in glob.glob(os.path.join(imagedir, secure_filename(f'model-{model_id}') + '.*')): + os.remove(old) + + filename = secure_filename(f'model-{model_id}{ext}') + upload.save(os.path.join(imagedir, filename)) + + m.imageurl = f'{MODEL_IMAGE_URL_PREFIX}{filename}' + db.session.commit() + + return success_response(m.to_dict(), message='Model image uploaded') + + +@models_bp.route('/image/', methods=['GET']) +def serve_model_image(filename): + """Serve an uploaded model image (public - asset detail pages read it).""" + return send_from_directory(_modelimage_dir(), filename) + + +@models_bp.route('//image', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_model_image(model_id: int): + """Clear a model image and delete the uploaded file if we own it. + + External URLs and the shipped /images/models/* assets are never touched on + disk - only the imageurl field is cleared. + """ + m = db.session.get(Model, model_id) + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + url = m.imageurl or '' + if url.startswith(MODEL_IMAGE_URL_PREFIX): + # secure_filename strips any traversal; the file lives in our dir only. + filename = secure_filename(url[len(MODEL_IMAGE_URL_PREFIX):]) + path = os.path.join(_modelimage_dir(), filename) + if os.path.exists(path): + os.remove(path) + + m.imageurl = None + db.session.commit() + + return success_response(m.to_dict(), message='Model image removed') diff --git a/shopdb/core/api/modeltypes.py b/shopdb/core/api/modeltypes.py new file mode 100644 index 00000000..71def4c3 --- /dev/null +++ b/shopdb/core/api/modeltypes.py @@ -0,0 +1,164 @@ +"""Model Types API endpoints - Full CRUD. + +Types the vendor MODELS catalog (models.modeltypeid). Renamed from +machinetypes; the machines plugin now owns the "machinetypes" name. +""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ModelType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +modeltypes_bp = Blueprint('modeltypes', __name__) + + +@modeltypes_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_modeltypes(): + """List all model types with optional filtering.""" + page, per_page = get_pagination_params(request) + + query = ModelType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(ModelType.isactive == True) + + if category := request.args.get('category'): + query = query.filter(ModelType.category == category) + + # Exact-match natural-key lookup for idempotent import (type name). + if exactmodeltype := request.args.get('modeltype'): + query = query.filter(ModelType.modeltype == exactmodeltype) + + if search := request.args.get('search'): + query = query.filter(ModelType.modeltype.ilike(f'%{search}%')) + + query = query.order_by(ModelType.modeltype) + + items, total = paginate_query(query, page, per_page) + data = [mt.to_dict() for mt in items] + + return paginated_response(data, page, per_page, total) + + +@modeltypes_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_modeltype(type_id: int): + """Get a single model type.""" + mt = db.session.get(ModelType, type_id) + + if not mt: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model type with ID {type_id} not found', + http_code=404 + ) + + return success_response(mt.to_dict()) + + +@modeltypes_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_modeltype(): + """Create a new model type.""" + data = request.get_json() + + if not data or not data.get('modeltype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'modeltype is required') + + if ModelType.query.filter_by(modeltype=data['modeltype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Model type '{data['modeltype']}' already exists", + http_code=409 + ) + + mt = ModelType( + modeltype=data['modeltype'], + category=data.get('category', 'Equipment'), + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(mt) + apply_import_timestamps(mt, data) + db.session.commit() + + return success_response(mt.to_dict(), message='Model type created', http_code=201) + + +@modeltypes_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_modeltype(type_id: int): + """Update a model type.""" + mt = db.session.get(ModelType, type_id) + + if not mt: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check duplicate name + if 'modeltype' in data and data['modeltype'] != mt.modeltype: + if ModelType.query.filter_by(modeltype=data['modeltype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Model type '{data['modeltype']}' already exists", + http_code=409 + ) + + for key in ['modeltype', 'category', 'description', 'icon', 'isactive']: + if key in data: + setattr(mt, key, data[key]) + + apply_import_timestamps(mt, data) + db.session.commit() + return success_response(mt.to_dict(), message='Model type updated') + + +@modeltypes_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_modeltype(type_id: int): + """Delete (deactivate) a model type.""" + mt = db.session.get(ModelType, type_id) + + if not mt: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model type with ID {type_id} not found', + http_code=404 + ) + + # Check if any model uses this type + from shopdb.core.models import Model + if Model.query.filter_by(modeltypeid=type_id).first(): + return error_response( + ErrorCodes.CONFLICT, + 'Cannot delete model type: models are using it', + http_code=409 + ) + + mt.isactive = False + db.session.commit() + + return success_response(message='Model type deleted') diff --git a/shopdb/core/api/operatingsystems.py b/shopdb/core/api/operatingsystems.py new file mode 100644 index 00000000..f6fa4d49 --- /dev/null +++ b/shopdb/core/api/operatingsystems.py @@ -0,0 +1,146 @@ +"""Operating Systems API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import OperatingSystem +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +operatingsystems_bp = Blueprint('operatingsystems', __name__) + + +@operatingsystems_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_operatingsystems(): + """List all operating systems.""" + page, per_page = get_pagination_params(request) + + query = OperatingSystem.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(OperatingSystem.isactive == True) + + # Exact-match natural-key lookup for idempotent import. Legacy OS rows have + # only a name; pair with ?osversion= when versions are tracked separately. + if exactosname := request.args.get('osname'): + query = query.filter(OperatingSystem.osname == exactosname) + if exactosversion := request.args.get('osversion'): + query = query.filter(OperatingSystem.osversion == exactosversion) + + if search := request.args.get('search'): + query = query.filter(OperatingSystem.osname.ilike(f'%{search}%')) + + query = query.order_by(OperatingSystem.osname) + + items, total = paginate_query(query, page, per_page) + data = [os.to_dict() for os in items] + + return paginated_response(data, page, per_page, total) + + +@operatingsystems_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_operatingsystem(os_id: int): + """Get a single operating system.""" + os = db.session.get(OperatingSystem, os_id) + + if not os: + return error_response( + ErrorCodes.NOT_FOUND, + f'Operating system with ID {os_id} not found', + http_code=404 + ) + + return success_response(os.to_dict()) + + +@operatingsystems_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_operatingsystem(): + """Create a new operating system.""" + data = request.get_json() + + if not data or not data.get('osname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'osname is required') + + existing = OperatingSystem.query.filter_by( + osname=data['osname'], + osversion=data.get('osversion') + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Operating system '{data['osname']} {data.get('osversion', '')}' already exists", + http_code=409 + ) + + os = OperatingSystem( + osname=data['osname'], + osversion=data.get('osversion'), + architecture=data.get('architecture'), + endoflife=data.get('endoflife') + ) + + db.session.add(os) + apply_import_timestamps(os, data) + db.session.commit() + + return success_response(os.to_dict(), message='Operating system created', http_code=201) + + +@operatingsystems_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_operatingsystem(os_id: int): + """Update an operating system.""" + os = db.session.get(OperatingSystem, os_id) + + if not os: + return error_response( + ErrorCodes.NOT_FOUND, + f'Operating system with ID {os_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + for key in ['osname', 'osversion', 'architecture', 'endoflife', 'isactive']: + if key in data: + setattr(os, key, data[key]) + + apply_import_timestamps(os, data) + db.session.commit() + return success_response(os.to_dict(), message='Operating system updated') + + +@operatingsystems_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_operatingsystem(os_id: int): + """Delete (deactivate) an operating system.""" + os = db.session.get(OperatingSystem, os_id) + + if not os: + return error_response( + ErrorCodes.NOT_FOUND, + f'Operating system with ID {os_id} not found', + http_code=404 + ) + + os.isactive = False + db.session.commit() + + return success_response(message='Operating system deleted') diff --git a/shopdb/core/api/plugins.py b/shopdb/core/api/plugins.py new file mode 100644 index 00000000..cbd74b2d --- /dev/null +++ b/shopdb/core/api/plugins.py @@ -0,0 +1,74 @@ +"""Plugin introspection + enable/disable API.""" + +from flask import Blueprint, current_app, request +from flask_jwt_extended import jwt_required + +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +from shopdb.utils.authz import require_role + +plugins_bp = Blueprint('plugins', __name__) + + +@plugins_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_plugins(): + """List all discovered plugins (enabled or not) with their metadata and + the framework contract version.""" + from shopdb import __contract_version__ + pm = current_app.extensions.get('plugin_manager') + plugins = pm.discover_available() if pm else [] + plugins.sort(key=lambda p: p['name']) + return success_response({ + 'contract_version': __contract_version__, + 'count': len(plugins), + 'plugins': plugins, + }) + + +@plugins_bp.route('/enabled', methods=['GET']) +@jwt_required(optional=True) +def list_enabled_plugins(): + """Return just the names of enabled plugins as a flat array. + + Cheap registry read (no DB). Exposed to anonymous callers on purpose: + the navigation endpoint already leaks the same enabled/disabled signal, + and the frontend needs it (including unauthenticated kiosk routes like + /tv) to gate plugin-owned routes. No metadata beyond the names. + """ + pm = current_app.extensions.get('plugin_manager') + names = pm.registry.get_enabled_plugins() if pm else [] + return success_response(sorted(names)) + + +@plugins_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def set_plugin_enabled(name: str): + """Enable or disable a plugin. Takes effect on the next app restart for + route/navigation changes.""" + data = request.get_json() or {} + if 'enabled' not in data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'enabled is required') + + pm = current_app.extensions.get('plugin_manager') + if not pm: + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Plugin manager unavailable', http_code=500) + + want = bool(data['enabled']) + ok = pm.enable_plugin(name) if want else pm.disable_plugin(name) + if not ok: + # enable/disable refused (unknown plugin, or a dependency conflict) + return error_response( + ErrorCodes.CONFLICT, + f"Could not {'enable' if want else 'disable'} '{name}' " + f"(check it exists and dependencies allow it)", + http_code=409 + ) + + return success_response( + {'name': name, 'enabled': want}, + message=f"Plugin {'enabled' if want else 'disabled'} " + f"(restart to apply route changes)" + ) diff --git a/shopdb/core/api/pluginui.py b/shopdb/core/api/pluginui.py new file mode 100644 index 00000000..90a41e8b --- /dev/null +++ b/shopdb/core/api/pluginui.py @@ -0,0 +1,108 @@ +"""Plugin frontend-contribution API endpoints (ADR-010). + +Consumers for the four data-only presentation hooks on BasePlugin: +get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation. + +Every endpoint copies the dashboard-widgets consumer semantics: skip disabled +plugins, tag each contributed dict with its originating plugin name, fail loud +in dev/test, isolate a broken plugin in prod. All routes are GET + jwt-optional, +matching the other read-only core endpoints; they expose data, not mutations. +""" + +from flask import Blueprint, request, current_app +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Asset +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +pluginui_bp = Blueprint('pluginui', __name__) + + +def _collect(hookname): + """Aggregate a data-only hook across enabled plugins. + + Same access pattern as dashboard.get_widgets: skip disabled plugins, inject + the plugin name on every returned dict, re-raise in dev/test, log-and-isolate + a broken plugin in prod. Returns the merged list (unsorted). + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + return [] + + merged = [] + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + for entry in getattr(plugin, hookname)() or []: + entry['plugin'] = name + merged.append(entry) + except Exception: + # fail loud in dev/test, isolate broke plugin in prod + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception('Plugin %s %s failed', name, hookname) + return merged + + +@pluginui_bp.route('/settings-cards', methods=['GET']) +@jwt_required(optional=True) +def settings_cards(): + """Merge enabled plugins' settings-catalog cards (get_settings_cards hook). + + The frontend merges these into the core settingsNav catalog and renders + them in the settings rail + landing overview. + """ + cards = _collect('get_settings_cards') + cards.sort(key=lambda card: card.get('position', 99)) + return success_response(cards) + + +@pluginui_bp.route('/asset-panels', methods=['GET']) +@jwt_required(optional=True) +def asset_panels(): + """Return the asset-detail panels matching one asset's type (get_asset_panels). + + Query param assetid is required. A panel matches when its assettypes list + contains the asset's type key or the wildcard '*'. + """ + assetid = request.args.get('assetid', type=int) + if not assetid: + return error_response(ErrorCodes.VALIDATION_ERROR, 'assetid is required') + + asset = db.session.get(Asset, assetid) + if not asset: + return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404) + assettype = asset.assettype.assettype if asset.assettype else None + + matched = [] + for panel in _collect('get_asset_panels'): + assettypes = panel.get('assettypes') or [] + if '*' in assettypes or (assettype and assettype in assettypes): + matched.append(panel) + matched.sort(key=lambda panel: panel.get('position', 99)) + return success_response(matched) + + +@pluginui_bp.route('/map-overlays', methods=['GET']) +@jwt_required(optional=True) +def map_overlays(): + """Merge enabled plugins' map overlay declarations (get_map_overlays hook). + + The map fetches each overlay endpoint and decorates already-placed markers. + """ + overlays = _collect('get_map_overlays') + overlays.sort(key=lambda overlay: overlay.get('position', 99)) + return success_response(overlays) + + +@pluginui_bp.route('/asset-presentation', methods=['GET']) +@jwt_required(optional=True) +def asset_presentation(): + """Merge enabled plugins' asset-type presentation entries (hook). + + Search rows and cross-links use these to pick a type's icon and detail route. + """ + entries = _collect('get_asset_presentation') + return success_response(entries) diff --git a/shopdb/core/api/reports.py b/shopdb/core/api/reports.py new file mode 100644 index 00000000..c31d93f0 --- /dev/null +++ b/shopdb/core/api/reports.py @@ -0,0 +1,636 @@ +"""Reports API endpoints.""" + +import csv +import io +from datetime import datetime, timezone +from flask import Blueprint, request, Response, current_app +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ( + Asset, AssetType, AssetStatus, + Application +) +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_permission + +reports_bp = Blueprint('reports', __name__) + + +def generate_csv(data: list, columns: list) -> str: + """Generate CSV string from data.""" + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(columns) + for row in data: + writer.writerow([row.get(col, '') for col in columns]) + return output.getvalue() + + +# ============================================================================= +# Report: Machines by Type +# ============================================================================= + +@reports_bp.route('/machines-by-type', methods=['GET']) +@jwt_required(optional=True) +def machines_by_type(): + """ + Report: Machine count grouped by machine type. + + Query parameters: + - businessunitid: Filter by business unit + - format: 'json' (default) or 'csv' + """ + try: + from plugins.machines.models import Machine, MachineType + except ImportError: + return error_response( + ErrorCodes.NOT_FOUND, + 'Machines plugin not installed', + http_code=404 + ) + + query = db.session.query( + MachineType.machinetype, + MachineType.description, + db.func.count(Machine.machineid).label('count') + ).outerjoin(Machine, Machine.machinetypeid == MachineType.machinetypeid + ).outerjoin(Asset, Asset.assetid == Machine.assetid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + MachineType.isactive == True + ) + + # Business unit filter + if bu_id := request.args.get('businessunitid'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + # Dualpath single-machine collapse: exclude the secondary bay so a dual-bay + # pair counts once per type bucket. Gated on the site setting (default on). + from shopdb.core.services.dualpath import ( + resolve_dualpath_pairs, dualpath_single_machine_enabled) + if dualpath_single_machine_enabled(): + secondaryassetids = resolve_dualpath_pairs().secondaryassetids + if secondaryassetids: + query = query.filter( + db.or_(Machine.assetid.is_(None), + Machine.assetid.notin_(secondaryassetids))) + + query = query.group_by( + MachineType.machinetypeid, + MachineType.machinetype, + MachineType.description + ).order_by(MachineType.machinetype) + + results = query.all() + data = [{'machinetype': r.machinetype, 'description': r.description or '', 'count': r.count} for r in results] + total = sum(r['count'] for r in data) + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['machinetype', 'description', 'count']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=machines_by_type.csv'} + ) + + return success_response({ + 'report': 'machines_by_type', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': total + }) + + +# ============================================================================= +# Report: Assets by Status +# ============================================================================= + +@reports_bp.route('/assets-by-status', methods=['GET']) +@jwt_required(optional=True) +def assets_by_status(): + """ + Report: Asset count grouped by status. + + Query parameters: + - assettypeid: Filter by asset type + - businessunitid: Filter by business unit + - format: 'json' (default) or 'csv' + """ + query = db.session.query( + AssetStatus.status, + AssetStatus.color, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.statusid == AssetStatus.statusid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + AssetStatus.isactive == True + ) + + # Asset type filter + if type_id := request.args.get('assettypeid'): + query = query.filter(Asset.assettypeid == int(type_id)) + + # Business unit filter + if bu_id := request.args.get('businessunitid'): + query = query.filter(Asset.businessunitid == int(bu_id)) + + query = query.group_by( + AssetStatus.statusid, + AssetStatus.status, + AssetStatus.color + ).order_by(AssetStatus.status) + + results = query.all() + data = [{'status': r.status, 'color': r.color or '', 'count': r.count} for r in results] + total = sum(r['count'] for r in data) + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['status', 'count']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=assets_by_status.csv'} + ) + + return success_response({ + 'report': 'assets_by_status', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': total + }) + + +# ============================================================================= +# Report: KB Popularity +# ============================================================================= + +@reports_bp.route('/kb-popularity', methods=['GET']) +@jwt_required(optional=True) +def kb_popularity(): + """ + Report: Most clicked knowledge base articles. + + Query parameters: + - limit: Number of results (default 20) + - format: 'json' (default) or 'csv' + """ + limit = min(int(request.args.get('limit', 20)), 100) + + # Knowledge Base is a plugin; degrade gracefully if it is not installed. + try: + from plugins.knowledgebase.models import KnowledgeBase + except ImportError: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'KB popularity requires the knowledgebase plugin', + http_code=503) + + articles = KnowledgeBase.query.filter( + KnowledgeBase.isactive == True + ).order_by( + KnowledgeBase.clicks.desc() + ).limit(limit).all() + + data = [] + for kb in articles: + data.append({ + 'linkid': kb.linkid, + 'shortdescription': kb.shortdescription, + 'application': kb.application.appname if kb.application else None, + 'clicks': kb.clicks or 0, + 'linkurl': kb.linkurl + }) + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['linkid', 'shortdescription', 'application', 'clicks', 'linkurl']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=kb_popularity.csv'} + ) + + return success_response({ + 'report': 'kb_popularity', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': len(data) + }) + + +# ============================================================================= +# Report: Software Compliance +# ============================================================================= + +@reports_bp.route('/software-compliance', methods=['GET']) +@jwt_required(optional=True) +def software_compliance(): + """ + Report: Required applications vs installed (per PC). + + Shows which PCs have required applications installed. + + Query parameters: + - appid: Filter to specific application + - format: 'json' (default) or 'csv' + """ + # Install tracking lives in the computers plugin; degrade gracefully if + # it is not installed. + try: + from plugins.computers.models import Computer, ComputerInstalledApp + except ImportError: + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Software compliance requires the computers plugin', + http_code=503) + + # Get required applications + required_apps = Application.query.filter( + Application.isactive == True, + Application.isrequired == True + ).all() + + if not required_apps: + return success_response({ + 'report': 'software_compliance', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': [], + 'message': 'No required applications defined' + }) + + app_filter = request.args.get('appid') + + data = [] + for app in required_apps: + if app_filter and str(app.appid) != app_filter: + continue + + # Get all PCs (computers) + total_pcs = Computer.query.join( + Asset, Asset.assetid == Computer.assetid + ).filter(Asset.isactive == True).count() + + # Get PCs with this app installed + installed_count = db.session.query(ComputerInstalledApp).join( + Computer, Computer.computerid == ComputerInstalledApp.computerid + ).join( + Asset, Asset.assetid == Computer.assetid + ).filter( + ComputerInstalledApp.appid == app.appid, + ComputerInstalledApp.isactive == True, + Asset.isactive == True + ).count() + + # Get list of non-compliant PCs + compliant_pc_ids = db.session.query(ComputerInstalledApp.computerid).filter( + ComputerInstalledApp.appid == app.appid, + ComputerInstalledApp.isactive == True + ).subquery() + + non_compliant_pcs = Computer.query.join( + Asset, Asset.assetid == Computer.assetid + ).filter( + Asset.isactive == True, + ~Computer.computerid.in_(compliant_pc_ids) + ).limit(100).all() + + compliance_rate = (installed_count / total_pcs * 100) if total_pcs > 0 else 0 + + data.append({ + 'appid': app.appid, + 'appname': app.appname, + 'totalpcs': total_pcs, + 'installed': installed_count, + 'notinstalled': total_pcs - installed_count, + 'compliancerate': round(compliance_rate, 1), + 'noncompliantpcs': [ + {'computerid': pc.computerid, + 'hostname': pc.hostname or (pc.asset.assetnumber if pc.asset else None)} + for pc in non_compliant_pcs + ] + }) + + if request.args.get('format') == 'csv': + # Simplified CSV (no nested data) + csv_rows = [] + for item in data: + csv_rows.append({ + 'appid': item['appid'], + 'appname': item['appname'], + 'totalpcs': item['totalpcs'], + 'installed': item['installed'], + 'notinstalled': item['notinstalled'], + 'compliancerate': item['compliancerate'] + }) + csv_data = generate_csv(csv_rows, ['appid', 'appname', 'totalpcs', 'installed', 'notinstalled', 'compliancerate']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=software_compliance.csv'} + ) + + return success_response({ + 'report': 'software_compliance', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': len(data) + }) + + +# ============================================================================= +# Report: Asset Inventory Summary +# ============================================================================= + +@reports_bp.route('/asset-inventory', methods=['GET']) +@jwt_required(optional=True) +def asset_inventory(): + """ + Report: Complete asset inventory summary. + + Query parameters: + - businessunitid: Filter by business unit + - locationid: Filter by location + - format: 'json' (default) or 'csv' + """ + # By asset type + by_type = db.session.query( + AssetType.assettype, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.assettypeid == AssetType.assettypeid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + AssetType.isactive == True + ) + + if bu_id := request.args.get('businessunitid'): + by_type = by_type.filter(Asset.businessunitid == int(bu_id)) + if loc_id := request.args.get('locationid'): + by_type = by_type.filter(Asset.locationid == int(loc_id)) + + by_type = by_type.group_by(AssetType.assettype).all() + + # By status + by_status = db.session.query( + AssetStatus.status, + AssetStatus.color, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.statusid == AssetStatus.statusid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + AssetStatus.isactive == True + ) + + if bu_id := request.args.get('businessunitid'): + by_status = by_status.filter(Asset.businessunitid == int(bu_id)) + if loc_id := request.args.get('locationid'): + by_status = by_status.filter(Asset.locationid == int(loc_id)) + + by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all() + + # By location + from shopdb.core.models import Location + by_location = db.session.query( + Location.locationname, + db.func.count(Asset.assetid).label('count') + ).outerjoin(Asset, Asset.locationid == Location.locationid + ).filter( + db.or_(Asset.isactive == True, Asset.assetid.is_(None)), + Location.isactive == True + ) + + if bu_id := request.args.get('businessunitid'): + by_location = by_location.filter(Asset.businessunitid == int(bu_id)) + + by_location = by_location.group_by(Location.locationname).all() + + data = { + 'bytype': [{'type': r.assettype, 'count': r.count} for r in by_type], + 'bystatus': [{'status': r.status, 'color': r.color, 'count': r.count} for r in by_status], + 'bylocation': [{'location': r.locationname, 'count': r.count} for r in by_location] + } + + total = sum(r['count'] for r in data['bytype']) + + if request.args.get('format') == 'csv': + # Create multiple sections in CSV + csv_output = io.StringIO() + + csv_output.write("Asset Inventory Report\n") + csv_output.write(f"Generated: {datetime.now(timezone.utc).replace(tzinfo=None).isoformat()}\n\n") + + csv_output.write("By Type\n") + csv_output.write("Type,Count\n") + for r in data['bytype']: + csv_output.write(f"{r['type']},{r['count']}\n") + + csv_output.write("\nBy Status\n") + csv_output.write("Status,Count\n") + for r in data['bystatus']: + csv_output.write(f"{r['status']},{r['count']}\n") + + csv_output.write("\nBy Location\n") + csv_output.write("Location,Count\n") + for r in data['bylocation']: + csv_output.write(f"{r['location']},{r['count']}\n") + + return Response( + csv_output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=asset_inventory.csv'} + ) + + return success_response({ + 'report': 'asset_inventory', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': total + }) + + +# ============================================================================= +# Available Reports List +# ============================================================================= + +# ============================================================================= +# Report: PC-Machine Relationships +# ============================================================================= + +@reports_bp.route('/pc-relationships', methods=['GET']) +@jwt_required(optional=True) +def pc_relationships(): + """ + Report: PCs with relationships to shop floor machines. + + Returns machine number, vendor, model, PC hostname, and PC IP address + for all active PC-machine relationships. + + Query parameters: + - format: 'json' (default) or 'csv' + """ + # Asset relationships where a computer (source) relates to a machine + # (target) - the asset-model equivalent of the legacy PC->machine links. + sql = db.text(""" + SELECT + eq.assetnumber AS machine_number, + v.vendor AS vendor, + mo.modelnumber AS model, + COALESCE(cpc.hostname, pc.assetnumber) AS hostname, + c.ipaddress AS ip + FROM assetrelationships ar + JOIN assets pc ON ar.sourceassetid = pc.assetid + JOIN computers cpc ON cpc.assetid = pc.assetid + JOIN assets eq ON ar.targetassetid = eq.assetid + JOIN machines eqx ON eqx.assetid = eq.assetid + LEFT JOIN communications c ON c.assetid = pc.assetid AND c.isprimary = 1 AND c.comtypeid = 1 + LEFT JOIN models mo ON eqx.modelnumberid = mo.modelnumberid + LEFT JOIN vendors v ON mo.vendorid = v.vendorid + WHERE ar.isactive = 1 + AND eq.assetnumber IS NOT NULL AND eq.assetnumber != '' + ORDER BY eq.assetnumber + """) + + results = db.session.execute(sql).fetchall() + data = [{ + 'machine_number': r.machine_number, + 'vendor': r.vendor or '', + 'model': r.model or '', + 'hostname': r.hostname or '', + 'ip': r.ip or '' + } for r in results] + + if request.args.get('format') == 'csv': + csv_data = generate_csv(data, ['machine_number', 'vendor', 'model', 'hostname', 'ip']) + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=pc_relationships.csv'} + ) + + return success_response({ + 'report': 'pc_relationships', + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + 'data': data, + 'total': len(data) + }) + + +@reports_bp.route('/email', methods=['POST']) +@jwt_required() +@require_permission('reports.export') +def email_report(): + """Email a report's data as an HTML table (on-demand delivery). + + Request: + { + "subject": "Warranty Report", + "columns": [{"key": "vendor", "label": "Vendor"}, ...], + "rows": [{"vendor": "...", ...}, ...], + "intro": "optional lead paragraph", + "to": "addr@example.com" // optional; defaults to alert_recipients + } + + Recipients default to the site's Alert Recipients when `to` is omitted, so + this doubles as the alert delivery path. There is no scheduler in this app: + sending is on-demand. To automate it, point an external cron job at this + endpoint using an API token (PAT) scoped to reports.export. + """ + from shopdb.utils.mailer import get_smtp_config, render_table_email, try_send + + data = request.get_json() or {} + subject = data.get('subject') or 'ShopDB report' + columns = data.get('columns') or [] + rows = data.get('rows') or [] + + config = get_smtp_config() + recipient = data.get('to') or config['alert_recipients'] + + if not config['enabled'] or not config['host']: + return success_response( + {'sent': False, 'reason': 'notconfigured'}, + message='Email is not configured (SMTP disabled or host unset).') + if not recipient: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'No recipient. Provide "to" or set Alert Recipients.') + + html, text = render_table_email( + subject, columns, rows, intro=data.get('intro')) + ok, error = try_send(recipient, subject, html, text=text) + if ok: + return success_response({'sent': True}, message='Report emailed.') + return success_response( + {'sent': False, 'error': error}, + message='Report email failed: ' + (error or 'unknown error')) + + +@reports_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_reports(): + """List all available reports.""" + reports = [ + { + 'id': 'machines-by-type', + 'name': 'Machines by Type', + 'description': 'Machine count grouped by machine type', + 'endpoint': '/api/reports/machines-by-type', + 'category': 'inventory' + }, + { + 'id': 'assets-by-status', + 'name': 'Assets by Status', + 'description': 'Asset count grouped by status', + 'endpoint': '/api/reports/assets-by-status', + 'category': 'inventory' + }, + { + 'id': 'kb-popularity', + 'name': 'KB Popularity', + 'description': 'Most clicked knowledge base articles', + 'endpoint': '/api/reports/kb-popularity', + 'category': 'usage' + }, + { + 'id': 'software-compliance', + 'name': 'Software Compliance', + 'description': 'Required applications vs installed', + 'endpoint': '/api/reports/software-compliance', + 'category': 'compliance' + }, + { + 'id': 'asset-inventory', + 'name': 'Asset Inventory Summary', + 'description': 'Complete asset inventory breakdown', + 'endpoint': '/api/reports/asset-inventory', + 'category': 'inventory' + }, + { + 'id': 'pc-relationships', + 'name': 'PC-Machine Relationships', + 'description': 'PCs with relationships to shop floor machines', + 'endpoint': '/api/reports/pc-relationships', + 'category': 'inventory' + } + ] + + # Merge report cards contributed by enabled plugins (get_reports hook). + # Same access pattern as dashboard.get_navigation: skip disabled plugins, + # fail loud in dev/test, isolate a broken plugin in prod. + pm = current_app.extensions.get('plugin_manager') + if pm: + for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue + try: + for entry in plugin.get_reports() or []: + entry['plugin'] = name + reports.append(entry) + except Exception: + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_reports failed', name) + + return success_response({ + 'reports': reports, + 'total': len(reports) + }) diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py new file mode 100644 index 00000000..638db017 --- /dev/null +++ b/shopdb/core/api/search.py @@ -0,0 +1,912 @@ +"""Global search API endpoint with full search parity.""" + +import re +import ipaddress +import logging + +from datetime import datetime, timezone +from flask import Blueprint, request, current_app +from flask_jwt_extended import jwt_required +from sqlalchemy.orm import joinedload + +from shopdb.extensions import db +from shopdb.core.models import ( + Application, Setting, + Asset, AssetType, Communication, Vendor, Model, + CustomField, CustomFieldValue +) +from shopdb.core.api.settings import get_cached_settings +from shopdb.utils.responses import success_response + +logger = logging.getLogger(__name__) + +search_bp = Blueprint('search', __name__) + + +def _require_enabled(name): + """Raise ImportError when the named plugin is disabled. + + Each plugin-scoped search block already catches ImportError and skips the + domain, so a disabled plugin is treated exactly like an absent one: its rows + drop out of search results. Honors runtime enable/disable. + """ + pm = current_app.extensions.get('plugin_manager') + if pm and not pm.registry.is_enabled(name): + raise ImportError(f'{name} plugin disabled') + +# Shipped GE defaults. Settings override these per-site; identical fallbacks +# live here so this consumer works even if the settings seed has not run. +SERVICENOW_URL_DEFAULT = ( + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/' + '0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/' + 'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' +) +SERVICENOW_PREFIXES_DEFAULT = 'GEINC,GECHG,GERIT,GESCT' +EMPLOYEEID_PATTERN_DEFAULT = r'^\d{9}$' + + +def _get_search_integrations(): + """Resolve the settings-driven search integration config. + + Reads employeeid_pattern, servicenow_ticket_prefixes, servicenow_enabled + and servicenow_search_url from cached settings, falling back to the shipped + GE defaults for any missing key. An invalid employeeid_pattern regex falls + back to the default rather than raising (search must never 500 on bad + config). ServiceNow is inactive when disabled, when the URL is blank, or + when no ticket prefixes are configured. + """ + settings = get_cached_settings() + + # Employee-ID pattern. Bad regex falls back so search never 500s. + pattern = settings.get('employeeid_pattern') or EMPLOYEEID_PATTERN_DEFAULT + try: + employeeid_re = re.compile(pattern) + except re.error: + employeeid_re = re.compile(EMPLOYEEID_PATTERN_DEFAULT) + + # Ticket prefixes -> case-insensitive alternation built at request time. + prefixes_raw = settings.get('servicenow_ticket_prefixes') + if prefixes_raw is None: + prefixes_raw = SERVICENOW_PREFIXES_DEFAULT + prefixes = [p.strip() for p in str(prefixes_raw).split(',') if p.strip()] + + servicenow_enabled = settings.get('servicenow_enabled') + if servicenow_enabled is None: + servicenow_enabled = True + + servicenow_url = settings.get('servicenow_search_url') + if servicenow_url is None: + servicenow_url = SERVICENOW_URL_DEFAULT + + servicenow_active = bool(servicenow_enabled) and bool(servicenow_url) and bool(prefixes) + + prefix_re = None + if servicenow_active: + alternation = '|'.join(re.escape(p) for p in prefixes) + prefix_re = re.compile(r'^(' + alternation + r')\d+', re.IGNORECASE) + + return { + 'employeeid_re': employeeid_re, + 'prefix_re': prefix_re, + 'servicenow_active': servicenow_active, + 'servicenow_url': servicenow_url, + } + + +def _classify_query(query, integrations): + """Analyze the query string to determine its nature.""" + prefix_re = integrations['prefix_re'] + sn_match = prefix_re.match(query) if prefix_re else None + return { + 'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)), + 'is_sso': bool(integrations['employeeid_re'].match(query)), + 'is_servicenow': bool(sn_match), + 'servicenow_prefix': sn_match.group(1) if sn_match else None, + 'is_fqdn': bool(re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', query)), + } + + +def _get_asset_result(asset, query, relevance=None): + """Build a search result dict from an Asset object.""" + asset_type_name = asset.assettype.assettype if asset.assettype else 'asset' + + plugin_id = asset.assetid + if asset_type_name == 'machine' and hasattr(asset, 'machine') and asset.machine: + plugin_id = asset.machine.machineid + elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer: + plugin_id = asset.computer.computerid + elif asset_type_name == 'network_device' and hasattr(asset, 'network_device') and asset.network_device: + plugin_id = asset.network_device.networkdeviceid + elif asset_type_name == 'printer' and hasattr(asset, 'printer') and asset.printer: + plugin_id = asset.printer.printerid + elif asset_type_name == 'measuring_tool' and hasattr(asset, 'measuringtool') and asset.measuringtool: + plugin_id = asset.measuringtool.measuringtoolid + + url_map = { + 'machine': f"/machines/{plugin_id}", + 'computer': f"/pcs/{plugin_id}", + 'network_device': f"/network/{plugin_id}", + 'printer': f"/printers/{plugin_id}", + 'measuring_tool': f"/measuringtools/{plugin_id}", + } + url = url_map.get(asset_type_name, f"/assets/{asset.assetid}") + + display_name = asset.display_name + subtitle = None + if asset.name and asset.assetnumber != asset.name: + subtitle = asset.assetnumber + + location_name = asset.location.locationname if asset.location else None + + if relevance is None: + relevance = 15 + + return { + 'type': asset_type_name, + 'id': plugin_id, + 'title': display_name, + 'subtitle': subtitle, + 'location': location_name, + 'url': url, + 'relevance': relevance + } + + +def _search_applications(query, search_term): + """Search Applications by name and description.""" + results = [] + try: + apps = Application.query.filter( + Application.isactive == True, + db.or_( + Application.appname.ilike(search_term), + Application.appdescription.ilike(search_term) + ) + ).limit(10).all() + + for app in apps: + relevance = 20 + if query.lower() == app.appname.lower(): + relevance = 100 + elif query.lower() in app.appname.lower(): + relevance = 50 + + results.append({ + 'type': 'application', + 'id': app.appid, + 'title': app.appname, + 'subtitle': app.appdescription[:100] if app.appdescription else None, + 'url': f"/applications/{app.appid}", + 'relevance': relevance + }) + except Exception as e: + logger.error(f"Application search failed: {e}") + return results + + +def _search_knowledgebase(query, search_term): + """Search Knowledge Base by description and keywords.""" + results = [] + try: + _require_enabled('knowledgebase') + from plugins.knowledgebase.models import KnowledgeBase + kb_articles = KnowledgeBase.query.filter( + KnowledgeBase.isactive == True, + db.or_( + KnowledgeBase.shortdescription.ilike(search_term), + KnowledgeBase.keywords.ilike(search_term) + ) + ).limit(20).all() + + for kb in kb_articles: + relevance = 10 + (kb.clicks or 0) * 0.1 + if kb.keywords and query.lower() in kb.keywords.lower(): + relevance += 15 + + results.append({ + 'type': 'knowledgebase', + 'id': kb.linkid, + 'title': kb.shortdescription, + 'subtitle': kb.application.appname if kb.application else None, + 'url': f"/knowledgebase/{kb.linkid}", + 'linkurl': kb.linkurl, + 'relevance': relevance + }) + except ImportError: + pass # knowledgebase plugin absent or disabled + except Exception as e: + logger.error(f"KnowledgeBase search failed: {e}") + return results + + +def _employee_rows(search_term): + """Employee rows matching search_term, from the SELFHOSTED directory table + or the external HR DB per the employee_directory_mode setting. Returns dicts + shaped like the external query (SSO/First_Name/Last_Name/Team/Role).""" + settings = get_cached_settings() + mode = (settings.get('employee_directory_mode') or 'external').lower() + if mode == 'selfhosted': + try: + from plugins.employees.models import DirectoryEmployee + except ImportError: + return [] + rows = DirectoryEmployee.query.filter( + db.or_( + DirectoryEmployee.firstname.ilike(search_term), + DirectoryEmployee.lastname.ilike(search_term), + db.cast(DirectoryEmployee.sso, db.String).ilike(search_term), + ) + ).order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).limit(10).all() + return [{'SSO': e.sso, 'First_Name': e.firstname or '', + 'Last_Name': e.lastname or '', 'Team': e.team, 'Role': e.role} + for e in rows] + # External HR DB: shared env-backed connection helper; never hardcode creds. + from shopdb.utils.employee_db import employee_connection + emp_conn = employee_connection() + with emp_conn.cursor() as cur: + cur.execute(''' + SELECT SSO, First_Name, Last_Name, Team, Role + FROM employees + WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s + ORDER BY Last_Name, First_Name LIMIT 10 + ''', (search_term, search_term, search_term)) + rows = cur.fetchall() + emp_conn.close() + return rows + + +def _search_employees(query, search_term): + """Search employees (selfhosted directory or external HR DB, per mode).""" + results = [] + try: + for emp in _employee_rows(search_term): + full_name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}" + sso_str = str(emp['SSO']) + + relevance = 20 + if query == sso_str: + relevance = 100 + elif query.lower() == full_name.lower(): + relevance = 95 + elif query.lower() in full_name.lower(): + relevance = 60 + + results.append({ + 'type': 'employee', + 'id': emp['SSO'], + 'title': full_name, + 'subtitle': emp.get('Team') or emp.get('Role') or f"SSO: {sso_str}", + 'url': f"/employees/{emp['SSO']}", + 'relevance': relevance + }) + except Exception as e: + logger.error(f"Employee search failed: {e}") + return results + + +def _search_assets(query, search_term): + """Search unified Assets table by number, name, serial, notes.""" + results = [] + try: + assets = Asset.query.join(AssetType).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Asset.assetnumber.ilike(search_term), + Asset.name.ilike(search_term), + Asset.serialnumber.ilike(search_term), + Asset.notes.ilike(search_term) + ) + ).limit(15).all() + + for asset in assets: + relevance = 15 + if asset.assetnumber and query.lower() == asset.assetnumber.lower(): + relevance = 100 + elif asset.name and query.lower() == asset.name.lower(): + relevance = 90 + elif asset.serialnumber and query.lower() == asset.serialnumber.lower(): + relevance = 85 + elif asset.name and query.lower() in asset.name.lower(): + relevance = 50 + + results.append(_get_asset_result(asset, query, relevance)) + except Exception as e: + logger.error(f"Asset search failed: {e}") + return results + + +def _search_measuringtools(query, search_term): + """Search measuring tools, including the gaugelabreference identifier. + + The generic asset search already matches number/name/serial across all asset + types; this gated searcher adds gaugelabreference (the gage tag the gage lab + searches by) and drops out when the measuringtools plugin is disabled. + """ + results = [] + try: + _require_enabled('measuringtools') + from plugins.measuringtools.models import MeasuringTool + assets = db.session.query(Asset).join( + MeasuringTool, MeasuringTool.assetid == Asset.assetid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Asset.assetnumber.ilike(search_term), + Asset.name.ilike(search_term), + Asset.serialnumber.ilike(search_term), + Asset.gaugelabreference.ilike(search_term), + ) + ).limit(15).all() + + for asset in assets: + relevance = 15 + if asset.assetnumber and query.lower() == asset.assetnumber.lower(): + relevance = 100 + elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower(): + relevance = 90 + elif asset.serialnumber and query.lower() == asset.serialnumber.lower(): + relevance = 85 + elif asset.name and query.lower() in asset.name.lower(): + relevance = 50 + results.append(_get_asset_result(asset, query, relevance)) + except ImportError: + pass # measuringtools plugin absent or disabled + except Exception as e: + logger.error(f"Measuring tool search failed: {e}") + return results + + +def _search_customfields(query, search_term): + """Search custom-field VALUES for fields flagged searchable. + + Joins CustomFieldValue -> CustomField (searchable + active) -> Asset (active) + and emits a normal asset result routed to that asset's detail page. Results + are deduped against built-in-field asset matches by the shared (type, id) key + in global_search, and the asset's search__enabled domain toggle is + applied by the same end-of-request domain filter. The matched field label is + put in the subtitle so a value hit reads sensibly. + """ + results = [] + try: + rows = db.session.query(Asset, CustomField).join( + CustomFieldValue, CustomFieldValue.assetid == Asset.assetid + ).join( + CustomField, CustomField.fieldid == CustomFieldValue.fieldid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + CustomField.searchable == True, + CustomField.isactive == True, + CustomFieldValue.value.ilike(search_term), + ).limit(15).all() + + for asset, field in rows: + result = _get_asset_result(asset, query, relevance=40) + result['subtitle'] = field.label + results.append(result) + except Exception as e: + logger.error(f"Custom field search failed: {e}") + return results + + +def _search_by_ip(query, search_term): + """Search Communications table for IP address matches.""" + results = [] + try: + comms = Communication.query.filter( + Communication.ipaddress.ilike(search_term) + ).options( + joinedload(Communication.asset).joinedload(Asset.assettype), + joinedload(Communication.asset).joinedload(Asset.location), + ).limit(10).all() + + seen_assets = set() + for comm in comms: + asset = comm.asset + if not asset or not asset.isactive or asset.assetid in seen_assets: + continue + seen_assets.add(asset.assetid) + + relevance = 80 if query == comm.ipaddress else 40 + result = _get_asset_result(asset, query, relevance) + result['subtitle'] = comm.ipaddress + results.append(result) + except Exception as e: + logger.error(f"IP search failed: {e}") + return results + + +def _search_subnets(query): + """Find which subnet an IP address belongs to.""" + results = [] + try: + _require_enabled('network') + from plugins.network.models import Subnet + ip_obj = ipaddress.ip_address(query) + subnets = Subnet.query.filter(Subnet.isactive == True).all() + for subnet in subnets: + try: + network = ipaddress.ip_network(subnet.cidr, strict=False) + if ip_obj in network: + results.append({ + 'type': 'subnet', + 'id': subnet.subnetid, + 'title': f'{subnet.name} ({subnet.cidr})', + 'subtitle': subnet.description or subnet.subnettype, + 'url': f'/network', + 'relevance': 70 + }) + except ValueError: + continue + except ImportError: + pass + except Exception as e: + logger.error(f"Subnet search failed: {e}") + return results + + +def _search_hostnames(query, search_term): + """Search hostname fields across Computer, Printer, NetworkDevice.""" + results = [] + + # Search Computers + try: + _require_enabled('computers') + from plugins.computers.models import Computer + computers = Computer.query.filter( + Computer.hostname.ilike(search_term) + ).options( + joinedload(Computer.asset).joinedload(Asset.assettype), + joinedload(Computer.asset).joinedload(Asset.location), + ).limit(10).all() + + for comp in computers: + if comp.asset and comp.asset.isactive: + relevance = 85 if query.lower() == (comp.hostname or '').lower() else 40 + result = _get_asset_result(comp.asset, query, relevance) + result['subtitle'] = comp.hostname + results.append(result) + except ImportError: + pass + except Exception as e: + logger.error(f"Computer hostname search failed: {e}") + + # Search Printers + try: + _require_enabled('printers') + from plugins.printers.models import Printer + printers = Printer.query.filter( + db.or_( + Printer.hostname.ilike(search_term), + Printer.sharename.ilike(search_term), + Printer.windowsname.ilike(search_term), + ) + ).options( + joinedload(Printer.asset).joinedload(Asset.assettype), + joinedload(Printer.asset).joinedload(Asset.location), + ).limit(10).all() + + for printer in printers: + if printer.asset and printer.asset.isactive: + match_field = printer.hostname or printer.sharename or '' + relevance = 85 if query.lower() == match_field.lower() else 40 + result = _get_asset_result(printer.asset, query, relevance) + result['subtitle'] = printer.hostname or printer.sharename + results.append(result) + except ImportError: + pass + except Exception as e: + logger.error(f"Printer hostname search failed: {e}") + + # Search Network Devices + try: + _require_enabled('network') + from plugins.network.models import NetworkDevice + devices = NetworkDevice.query.filter( + NetworkDevice.hostname.ilike(search_term) + ).options( + joinedload(NetworkDevice.asset).joinedload(Asset.assettype), + joinedload(NetworkDevice.asset).joinedload(Asset.location), + ).limit(10).all() + + for device in devices: + if device.asset and device.asset.isactive: + relevance = 85 if query.lower() == (device.hostname or '').lower() else 40 + result = _get_asset_result(device.asset, query, relevance) + result['subtitle'] = device.hostname + results.append(result) + except ImportError: + pass + except Exception as e: + logger.error(f"Network device hostname search failed: {e}") + + return results + + +def _search_notifications(query, search_term): + """Search notifications with time-weighted relevance.""" + results = [] + try: + _require_enabled('notifications') + from plugins.notifications.models import Notification + + notifications = Notification.query.options( + joinedload(Notification.notificationtype) + ).filter( + db.or_( + Notification.notification.ilike(search_term), + Notification.ticketnumber.ilike(search_term) + ) + ).order_by(Notification.starttime.desc()).limit(15).all() + + now = datetime.now(timezone.utc).replace(tzinfo=None) + for notif in notifications: + base_relevance = 20 + if notif.ticketnumber and query.lower() == notif.ticketnumber.lower(): + base_relevance = 85 + + # Time-weighted relevance + if notif.is_current: + base_relevance *= 3 + elif notif.starttime and notif.starttime > now: + base_relevance *= 2 + elif notif.endtime and (now - notif.endtime).days < 7: + base_relevance = int(base_relevance * 1.5) + + results.append({ + 'type': 'notification', + 'id': notif.notificationid, + 'title': notif.title, + 'subtitle': notif.notificationtype.typename if notif.notificationtype else None, + 'url': f'/notifications', + 'relevance': min(int(base_relevance), 100), + 'ticketnumber': notif.ticketnumber, + 'iscurrent': notif.is_current + }) + except ImportError: + pass + except Exception as e: + logger.error(f"Notification search failed: {e}") + return results + + +def _search_vendor_model_type(query, search_term): + """Search assets by vendor name, model name, or machine/device type name.""" + results = [] + + # Machines: vendor, model, machinetype + try: + _require_enabled('machines') + from plugins.machines.models import Machine, MachineType + machine_assets = db.session.query(Asset).join( + Machine, Machine.assetid == Asset.assetid + ).outerjoin( + Vendor, Machine.vendorid == Vendor.vendorid + ).outerjoin( + Model, Machine.modelnumberid == Model.modelnumberid + ).outerjoin( + MachineType, Machine.machinetypeid == MachineType.machinetypeid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Vendor.vendor.ilike(search_term), + Model.modelnumber.ilike(search_term), + MachineType.machinetype.ilike(search_term) + ) + ).limit(10).all() + + for asset in machine_assets: + results.append(_get_asset_result(asset, query, 30)) + except ImportError: + pass + except Exception as e: + logger.error(f"Machine vendor/model/type search failed: {e}") + + # Printers: vendor, model, printertype + try: + _require_enabled('printers') + from plugins.printers.models import Printer, PrinterType + printer_assets = db.session.query(Asset).join( + Printer, Printer.assetid == Asset.assetid + ).outerjoin( + Vendor, Printer.vendorid == Vendor.vendorid + ).outerjoin( + Model, Printer.modelnumberid == Model.modelnumberid + ).outerjoin( + PrinterType, Printer.printertypeid == PrinterType.printertypeid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Vendor.vendor.ilike(search_term), + Model.modelnumber.ilike(search_term), + PrinterType.printertype.ilike(search_term) + ) + ).limit(10).all() + + for asset in printer_assets: + results.append(_get_asset_result(asset, query, 30)) + except ImportError: + pass + except Exception as e: + logger.error(f"Printer vendor/model/type search failed: {e}") + + # Network Devices: vendor, networkdevicetype + try: + _require_enabled('network') + from plugins.network.models import NetworkDevice, NetworkDeviceType + netdev_assets = db.session.query(Asset).join( + NetworkDevice, NetworkDevice.assetid == Asset.assetid + ).outerjoin( + Vendor, NetworkDevice.vendorid == Vendor.vendorid + ).outerjoin( + NetworkDeviceType, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Vendor.vendor.ilike(search_term), + NetworkDeviceType.networkdevicetype.ilike(search_term) + ) + ).limit(10).all() + + for asset in netdev_assets: + results.append(_get_asset_result(asset, query, 30)) + except ImportError: + pass + except Exception as e: + logger.error(f"Network device vendor/type search failed: {e}") + + return results + + +def _check_smart_redirect(query, classification): + """Check if query exactly matches a single entity for smart redirect.""" + # Exact SSO match + if classification['is_sso']: + try: + # Shared env-backed connection helper; never hardcode creds. + from shopdb.utils.employee_db import employee_connection + emp_conn = employee_connection() + with emp_conn.cursor() as cur: + cur.execute( + 'SELECT SSO, First_Name, Last_Name FROM employees WHERE SSO = %s LIMIT 1', + (query,) + ) + emp = cur.fetchone() + emp_conn.close() + if emp: + name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}" + return { + 'type': 'employee', + 'url': f"/employees/{emp['SSO']}", + 'label': name + } + except Exception: + pass + + # Exact asset number match + try: + asset = Asset.query.options( + joinedload(Asset.assettype), + ).filter( + Asset.assetnumber == query, + Asset.isactive == True + ).first() + if asset: + result = _get_asset_result(asset, query) + return { + 'type': result['type'], + 'url': result['url'], + 'label': asset.display_name + } + except Exception: + pass + + # Exact printer CSF/share name + try: + _require_enabled('printers') + from plugins.printers.models import Printer + printer = Printer.query.options( + joinedload(Printer.asset).joinedload(Asset.assettype), + ).filter( + db.or_( + Printer.sharename == query, + Printer.windowsname == query + ) + ).first() + if printer and printer.asset and printer.asset.isactive: + return { + 'type': 'printer', + 'url': f"/printers/{printer.printerid}", + 'label': printer.sharename or printer.asset.display_name + } + except ImportError: + pass + except Exception: + pass + + # Exact hostname match (FQDN or bare hostname) + hostname_plugins = [] + try: + _require_enabled('computers') + from plugins.computers.models import Computer + hostname_plugins.append(('computer', Computer, 'computerid', '/pcs')) + except ImportError: + pass + try: + _require_enabled('printers') + from plugins.printers.models import Printer + hostname_plugins.append(('printer', Printer, 'printerid', '/printers')) + except ImportError: + pass + try: + _require_enabled('network') + from plugins.network.models import NetworkDevice + hostname_plugins.append(('network_device', NetworkDevice, 'networkdeviceid', '/network')) + except ImportError: + pass + + for type_name, PluginModel, id_field, url_prefix in hostname_plugins: + try: + device = PluginModel.query.options( + joinedload(PluginModel.asset) + ).filter( + PluginModel.hostname == query + ).first() + if device and device.asset and device.asset.isactive: + return { + 'type': type_name, + 'url': f"{url_prefix}/{getattr(device, id_field)}", + 'label': device.hostname + } + except Exception: + pass + + # Exact IP match + if classification['is_ip']: + try: + comm = Communication.query.options( + joinedload(Communication.asset).joinedload(Asset.assettype) + ).filter( + Communication.ipaddress == query + ).first() + if comm and comm.asset and comm.asset.isactive: + result = _get_asset_result(comm.asset, query) + return { + 'type': result['type'], + 'url': result['url'], + 'label': f"{comm.asset.display_name} ({comm.ipaddress})" + } + except Exception: + pass + + return None + + +@search_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def global_search(): + """ + Global search across multiple entity types. + + Returns combined results from assets, applications, knowledge base, + employees, notifications, IP addresses, hostnames, and vendor/model/type. + Supports smart redirects and ServiceNOW ticket detection. + """ + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return success_response({ + 'results': [], + 'query': query, + 'message': 'Search query must be at least 2 characters' + }) + + if len(query) > 200: + return success_response({ + 'results': [], + 'query': query[:200], + 'message': 'Search query too long' + }) + + integrations = _get_search_integrations() + classification = _classify_query(query, integrations) + + # ServiceNOW prefix detection - return redirect immediately + if classification['is_servicenow']: + from urllib.parse import quote + servicenow_url = integrations['servicenow_url'].format(ticket=quote(query)) + return success_response({ + 'results': [], + 'query': query, + 'total': 0, + 'counts': {}, + 'redirect': { + 'type': 'servicenow', + 'url': servicenow_url, + 'label': f'Open {query} in ServiceNOW' + } + }) + + results = [] + search_term = f'%{query}%' + + # Run all search domains + results.extend(_search_applications(query, search_term)) + results.extend(_search_knowledgebase(query, search_term)) + results.extend(_search_employees(query, search_term)) + results.extend(_search_assets(query, search_term)) + results.extend(_search_measuringtools(query, search_term)) + results.extend(_search_customfields(query, search_term)) + results.extend(_search_notifications(query, search_term)) + results.extend(_search_hostnames(query, search_term)) + results.extend(_search_vendor_model_type(query, search_term)) + + # IP-specific searches + if classification['is_ip']: + results.extend(_search_by_ip(query, search_term)) + results.extend(_search_subnets(query)) + + # Sort by relevance (highest first) + results.sort(key=lambda x: x['relevance'], reverse=True) + + # Remove duplicates (prefer higher relevance) + seen_ids = {} + unique_results = [] + for r in results: + key = (r['type'], r['id']) + if key not in seen_ids: + seen_ids[key] = True + unique_results.append(r) + + # Drop result types disabled in Settings > Search (search__enabled). + # Missing key = enabled. One query, default-on. + disabled_types = { + s.key[len('search_'):-len('_enabled')] + for s in Setting.query.filter_by(category='search').all() + if s.get_typed_value() is False + } + if disabled_types: + unique_results = [r for r in unique_results if r['type'] not in disabled_types] + + # Compute type counts before truncation + type_counts = {} + for r in unique_results: + t = r['type'] + type_counts[t] = type_counts.get(t, 0) + 1 + + total_all = len(unique_results) + + # Limit total results + unique_results = unique_results[:50] + + # Check for smart redirect + response_data = { + 'results': unique_results, + 'query': query, + 'total': len(unique_results), + 'total_all': total_all, + 'counts': type_counts, + } + + redirect = _check_smart_redirect(query, classification) + if redirect: + response_data['redirect'] = redirect + + return success_response(response_data) diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py new file mode 100644 index 00000000..5f93cd30 --- /dev/null +++ b/shopdb/core/api/settings.py @@ -0,0 +1,911 @@ +"""Settings API routes.""" + +import os + +from flask import Blueprint, request, current_app, send_from_directory +from flask_jwt_extended import jwt_required, get_jwt_identity +from werkzeug.utils import secure_filename + +from shopdb.extensions import db, cache +from shopdb.core.models import Setting, AuditLog +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +from shopdb.utils.authz import require_permission, require_role + +settings_bp = Blueprint('settings', __name__) + +# Floor-map blueprint uploads live in the instance dir and are served publicly +# (the kiosk dashboards read them without auth). +MAP_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} + +# Branding logo uploads allow everything a map image does plus .ico (favicons). +BRANDING_IMAGE_EXTENSIONS = MAP_IMAGE_EXTENSIONS | {'.ico'} + +# Branding logo kinds and the setting key each one writes. +BRANDING_KIND_SETTINGS = { + 'site': 'site_logo', + 'qr': 'qr_logo', + 'badge': 'badge_logo', + 'favicon': 'site_favicon', +} + + +def _map_dir(): + return os.path.join(current_app.instance_path, 'maps') + + +def _branding_dir(): + return os.path.join(current_app.instance_path, 'branding') + +# Cache key for settings +SETTINGS_CACHE_KEY = 'system_settings' +SETTINGS_CACHE_TTL = 300 # 5 minutes + +# Placeholder returned in API responses for secret values so they are never +# exposed in plaintext. Sending it back on update is treated as "unchanged". +SECRET_MASK = '********' + +# Public-settings allowlist. An UNAUTHENTICATED caller (kiosk dashboards, print +# pages, the login screen, the setup router) may read only these; everything +# else (smtp_host, employee_db_host, zabbix_url, servicenow URLs, ...) needs a +# valid token. Secrets stay masked in both cases. Keep in sync with the keys +# frontend/src/utils/siteSettings.js + mapConfig.js + setupState.js read before +# login. Whole categories that are purely presentation are allowed wholesale; +# the rest are named keys so a new integration key does not leak by default. +PUBLIC_SETTING_CATEGORIES = {'branding', 'map'} +PUBLIC_SETTING_KEYS = { + 'site_base_url', 'facility_name', 'printer_hostname_template', + 'contact_email_domain', 'servicenow_enabled', 'setup_complete', +} + + +def _is_public_setting(setting) -> bool: + return (setting.category in PUBLIC_SETTING_CATEGORIES + or setting.key in PUBLIC_SETTING_KEYS) + +# Optional asset identifiers and the asset types they can be toggled on. +# Drives per-type seed keys and the Settings matrix UI. The asset type names +# match the AssetType.assettype values seeded by each plugin. +IDENTIFIER_LABELS = { + 'gaugelabreference': 'Gauge Lab Reference', + 'maintenancereference': 'Maintenance Reference', + 'fqdn': 'FQDN / hostname', +} +IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device', + 'measuring_tool'] + +# Global-search result types that can be toggled on/off independently of whether +# the owning plugin is enabled. Keys match the `type` field on search results; +# seed keys are search__enabled (boolean, default true). Drives the +# Settings "Search" toggles and the filter in shopdb/core/api/search.py. +SEARCH_DOMAINS = { + 'application': 'Applications', + 'knowledgebase': 'Knowledge Base', + 'employee': 'Employees', + 'machine': 'Machines', + 'computer': 'PCs', + 'printer': 'Printers', + 'network_device': 'Network Devices', + 'measuring_tool': 'Measuring Tools', + 'notification': 'Notifications', + 'subnet': 'Subnets', +} + +def _is_secret(key: str) -> bool: + return 'password' in key or 'token' in key or 'secret' in key + + +def _serialize_setting(setting): + """Serialize a setting, masking secret values so they never leave the API.""" + data = setting.to_dict() + if _is_secret(setting.key): + data['value'] = SECRET_MASK if setting.value else '' + return data + + +def get_cached_settings(): + """Get all settings from cache or database.""" + cached = cache.get(SETTINGS_CACHE_KEY) + if cached is not None: + return cached + + settings = Setting.query.all() + result = {s.key: s.get_typed_value() for s in settings} + cache.set(SETTINGS_CACHE_KEY, result, timeout=SETTINGS_CACHE_TTL) + return result + + +def invalidate_settings_cache(): + """Clear the settings cache.""" + cache.delete(SETTINGS_CACHE_KEY) + + +@settings_bp.route('/map-blueprint', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_map_blueprint(): + """Upload a floor-map blueprint image and point the setting at it. + + multipart/form-data: file=, theme=light|dark. Saves to the instance + maps dir and sets map_blueprint_ to the served URL. + """ + theme = (request.form.get('theme') or '').strip().lower() + if theme not in ('light', 'dark'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'theme must be light or dark') + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in MAP_IMAGE_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + os.makedirs(_map_dir(), exist_ok=True) + filename = secure_filename(f'blueprint-{theme}{ext}') + upload.save(os.path.join(_map_dir(), filename)) + + url = f'/api/settings/map-blueprint/{filename}' + key = f'map_blueprint_{theme}' + setting = Setting.query.filter_by(key=key).first() + if setting: + setting.value = url + else: + db.session.add(Setting(key=key, value=url, valuetype='string', category='map')) + db.session.commit() + invalidate_settings_cache() + return success_response({'key': key, 'value': url}, message='Blueprint uploaded') + + +@settings_bp.route('/map-blueprint/', methods=['GET']) +def serve_map_blueprint(filename): + """Serve an uploaded blueprint image (public - kiosks read it).""" + return send_from_directory(_map_dir(), filename) + + +@settings_bp.route('/branding-logo', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_branding_logo(): + """Upload a branding logo image and point the matching setting at it. + + multipart/form-data: file=, kind=site|qr|badge|favicon. Saves to the + instance branding dir and sets the matching branding setting to the served + URL. .ico is accepted in addition to the map image types (for favicons). + """ + kind = (request.form.get('kind') or '').strip().lower() + if kind not in BRANDING_KIND_SETTINGS: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'kind must be one of: ' + ', '.join(sorted(BRANDING_KIND_SETTINGS))) + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in BRANDING_IMAGE_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + os.makedirs(_branding_dir(), exist_ok=True) + filename = secure_filename(f'logo-{kind}{ext}') + upload.save(os.path.join(_branding_dir(), filename)) + + url = f'/api/settings/branding/{filename}' + key = BRANDING_KIND_SETTINGS[kind] + setting = Setting.query.filter_by(key=key).first() + if setting: + setting.value = url + else: + db.session.add(Setting(key=key, value=url, valuetype='string', category='branding')) + db.session.commit() + invalidate_settings_cache() + return success_response({'key': key, 'value': url}, message='Logo uploaded') + + +@settings_bp.route('/branding/', methods=['GET']) +def serve_branding_logo(filename): + """Serve an uploaded branding logo (public - kiosks/print pages read it).""" + return send_from_directory(_branding_dir(), filename) + + +@settings_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_settings(): + """List all settings, optionally filtered by category.""" + category = request.args.get('category') + + query = Setting.query + if category: + query = query.filter_by(category=category) + + settings = query.order_by(Setting.category, Setting.key).all() + # Unauthenticated callers see only the public allowlist (branding + a few + # bootstrap keys); an authed principal sees everything (secrets masked). + if get_jwt_identity() is None: + settings = [s for s in settings if _is_public_setting(s)] + return success_response([_serialize_setting(s) for s in settings]) + + +@settings_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_setting(key: str): + """Get a single setting by key.""" + setting = Setting.query.filter_by(key=key).first() + + if not setting: + return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404) + + # A non-public key is invisible to an unauthenticated caller (404, not 403, + # so its existence is not confirmed either). + if get_jwt_identity() is None and not _is_public_setting(setting): + return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404) + + return success_response(_serialize_setting(setting)) + + +@settings_bp.route('/', methods=['PUT']) +@jwt_required() +@require_permission('settings.edit') +def update_setting(key: str): + """Update a setting value.""" + data = request.get_json() + + if data is None or 'value' not in data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'value is required') + + setting = Setting.query.filter_by(key=key).first() + + # Upsert: create the row on first write (e.g. plugin config keys the setup + # wizard saves). New keys default to a plugin-scoped string setting. + if not setting: + setting = Setting(key=key, value='', valuetype='string', category='plugin') + db.session.add(setting) + + # Track old value for audit + old_value = setting.value + + value = data['value'] + + # A secret submitted as the mask placeholder means "leave unchanged" - the + # client only ever received the mask, so don't overwrite the real secret. + if _is_secret(key) and value == SECRET_MASK: + return success_response(_serialize_setting(setting), message='Setting unchanged') + + # Convert value to string for storage + if isinstance(value, bool): + setting.value = 'true' if value else 'false' + else: + setting.value = str(value) if value is not None else None + + # Audit log (mask sensitive values) + is_sensitive = _is_secret(key) + AuditLog.log('updated', 'Setting', entityname=key, changes={ + 'value': { + 'old': '***' if is_sensitive else old_value, + 'new': '***' if is_sensitive else setting.value + } + }) + + db.session.commit() + invalidate_settings_cache() + + return success_response(_serialize_setting(setting), message='Setting updated') + + +@settings_bp.route('/test-email', methods=['POST']) +@jwt_required() +@require_permission('settings.edit') +def test_email(): + """Send a test email to verify SMTP configuration. + + Request: { "to": "addr@example.com" } (falls back to alert_recipients) + + Returns a 200 with a `sent` flag either way. When SMTP is not configured + the response explains that gracefully; when a real send fails the SMTP error + is surfaced with any credential scrubbed out. + """ + from shopdb.utils.mailer import get_smtp_config, render_email, try_send + + data = request.get_json() or {} + config = get_smtp_config() + recipient = data.get('to') or config['alert_recipients'] + + if not config['enabled'] or not config['host']: + return success_response( + {'sent': False, 'reason': 'notconfigured'}, + message='Email is not configured (SMTP disabled or host unset).') + if not recipient: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'No recipient. Provide "to" or set Alert Recipients.') + + html, text = render_email( + 'ShopDB test email', + '

This is a test message confirming your SMTP settings work.

') + ok, error = try_send(recipient, 'ShopDB test email', html, text=text) + if ok: + return success_response({'sent': True}, message='Test email sent.') + return success_response( + {'sent': False, 'error': error}, + message='Test email failed: ' + (error or 'unknown error')) + + +@settings_bp.route('', methods=['POST']) +@jwt_required() +@require_permission('settings.edit') +def create_setting(): + """Create a new setting (admin only).""" + data = request.get_json() + + if not data or not data.get('key'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'key is required') + + if Setting.query.filter_by(key=data['key']).first(): + return error_response(ErrorCodes.CONFLICT, f"Setting '{data['key']}' already exists", http_code=409) + + value = data.get('value') + if isinstance(value, bool): + value_str = 'true' if value else 'false' + else: + value_str = str(value) if value is not None else None + + setting = Setting( + key=data['key'], + value=value_str, + valuetype=data.get('valuetype', 'string'), + category=data.get('category', 'general'), + description=data.get('description') + ) + + db.session.add(setting) + db.session.commit() + invalidate_settings_cache() + + return success_response(setting.to_dict(), message='Setting created', http_code=201) + + +def build_default_settings(): + """Return the full default-settings list (identifier toggles + static). + + Shared by the /settings/seed route and the `flask seed settings` CLI so + the two definitions never drift. + """ + # Asset identifier feature toggles, per identifier AND per asset type. + # Key format: identifier___enabled (boolean). Admins pick + # which optional identifiers show on which asset types. See ADR-001. + identifierdefaults = [ + { + 'key': f'identifier_{name}_{assettype}_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'identifiers', + 'description': f'Show the {label} identifier on {assettype} assets', + } + for name, label in IDENTIFIER_LABELS.items() + for assettype in IDENTIFIER_ASSETTYPES + ] + + # Per-domain global-search toggles (search__enabled). + searchdefaults = [ + { + 'key': f'search_{key}_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'search', + 'description': f'Include {label} in global search results', + } + for key, label in SEARCH_DOMAINS.items() + ] + + # Facility floor-map blueprint. Each site instance (ADR-004) points these + # at its own floor-plan image and pixel dimensions; the map frontend reads + # them instead of hardcoding one facility's plan. Defaults are a generic + # placeholder floor plan so an un-reconfigured install still renders. + mapdefaults = [ + { + 'key': 'map_blueprint_light', + 'value': '/static/images/floorplan-placeholder.svg', + 'valuetype': 'string', + 'category': 'map', + 'description': 'Floor-map blueprint image (light theme) for this facility' + }, + { + 'key': 'map_blueprint_dark', + 'value': '/static/images/floorplan-placeholder.svg', + 'valuetype': 'string', + 'category': 'map', + 'description': 'Floor-map blueprint image (dark theme) for this facility' + }, + { + 'key': 'map_width', + 'value': '3300', + 'valuetype': 'integer', + 'category': 'map', + 'description': 'Floor-map blueprint width in pixels (native size of the image)' + }, + { + 'key': 'map_height', + 'value': '2550', + 'valuetype': 'integer', + 'category': 'map', + 'description': 'Floor-map blueprint height in pixels (native size of the image)' + }, + ] + + # Site identity. Each instance (ADR-004) sets its own public URL - used for + # QR codes and any absolute link the app emits - and facility name shown on + # the shopfloor dashboard. Blank site_base_url falls back to the browsing + # origin so nothing breaks before a site configures it. + sitedefaults = [ + { + 'key': 'setup_complete', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'site', + 'description': 'Set true once the first-run setup wizard has been finished' + }, + { + 'key': 'employee_directory_mode', + 'value': 'selfhosted', + 'valuetype': 'string', + 'category': 'site', + 'description': "Employee directory source: 'selfhosted' (tables in this app, default) or 'external' (a separate HR database)" + }, + { + 'key': 'usb_directory_mode', + 'value': 'selfhosted', + 'valuetype': 'string', + 'category': 'site', + 'description': "USB check-in/out source: 'selfhosted' (tables in this app, default) or 'external' (a separate cmmc_usb database)" + }, + { + 'key': 'site_base_url', + 'value': '', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Public base URL of this site (scheme + host), e.g. https://shopdb.example.net. Used for QR codes and absolute links. Blank = use the browsing origin.' + }, + { + 'key': 'facility_name', + 'value': '', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Facility name shown on the shopfloor dashboard header (blank = frontend falls back to ShopDB)' + }, + { + 'key': 'pc_access_domain', + 'value': 'device.geaerospace.net', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.' + }, + { + 'key': 'employeeid_pattern', + 'value': r'^\d{9}$', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never errors.' + }, + { + 'key': 'dualpath_single_machine', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'site', + 'description': 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. Off = list and count both bays separately.' + }, + { + 'key': 'printer_hostname_template', + 'value': 'Printer-{ip}.printer.geaerospace.net', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Template for a printer hostname built from its IP. {ip} is the dash-separated IP address.' + }, + { + 'key': 'contact_email_domain', + 'value': 'geaerospace.com', + 'valuetype': 'string', + 'category': 'site', + 'description': 'Email domain appended to a contact SSO to build email/Teams links (sso@domain). Blank disables contact action buttons.' + }, + ] + + # Site branding. Each instance (ADR-004) can replace the shipped GE defaults + # with its own logos, favicon, and primary color. Blank values fall back to + # the built-in shipped assets so an un-reconfigured install still renders. + brandingdefaults = [ + { + 'key': 'site_logo', + 'value': '/ge-aerospace-logo.svg', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Main site logo shown in the app header and login page' + }, + { + 'key': 'qr_logo', + 'value': '/ge-monogram.svg', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Logo composited in the center of printer QR labels (blank = no QR overlay)' + }, + { + 'key': 'badge_logo', + 'value': '/ge-aerospace-logo.svg', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Logo shown on the machine badge print page' + }, + { + 'key': 'site_favicon', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Browser tab favicon (blank = shipped /favicon.svg)' + }, + { + 'key': 'brand_primary_color', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Primary brand color as a CSS color value (blank = built-in theme color)' + }, + { + 'key': 'brand_primary_dark_color', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Primary hover/active color (blank = derived by darkening the primary color ~15%)' + }, + { + 'key': 'brand_accent_color', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Accent color for secondary buttons and badges (blank = built-in theme color)' + }, + { + 'key': 'brand_sidebar_color', + 'value': '', + 'valuetype': 'string', + 'category': 'branding', + 'description': 'Sidebar background color (blank = built-in theme color)' + }, + ] + + # Printed QR/label targets. Blank template = QR links to the asset's own + # detail page on this instance; a non-blank value is a URL template with + # {placeholder} substitution so a site can point labels anywhere. + printingdefaults = [ + { + 'key': 'qr_target_printer', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for printer QR labels. Blank = link to the printer page. Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}.' + }, + { + 'key': 'qr_target_usb', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: {id}, {serialnumber}, {alias}.' + }, + { + 'key': 'usb_label_style', + 'value': 'barcode', + 'valuetype': 'string', + 'category': 'printing', + 'description': "USB mini-label code style: 'barcode' (CODE128 of the serial number) or 'qr' (QR code linking to the QR target)." + }, + { + 'key': 'qr_target_machine', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for machine labels. Blank = link to the machine page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.' + }, + { + 'key': 'qr_target_computer', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for computer labels. Blank = link to the computer page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.' + }, + { + 'key': 'qr_target_network_device', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for network-device labels. Blank = link to the device page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.' + }, + { + 'key': 'qr_target_measuring_tool', + 'value': '', + 'valuetype': 'string', + 'category': 'printing', + 'description': 'Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}.' + }, + { + 'key': 'label_default_style', + 'value': 'card', + 'valuetype': 'string', + 'category': 'printing', + 'description': "Default asset-label layout: 'card' (badge with image and identity) or 'plain' (just the code and a caption)." + }, + { + 'key': 'label_default_codetype', + 'value': 'qr', + 'valuetype': 'string', + 'category': 'printing', + 'description': "Default asset-label code type: 'qr' (QR code) or 'barcode' (CODE128)." + }, + ] + + # Per-asset-type default for what a label's code encodes. Machines default + # to their machine number (assetnumber), measuring tools to their inspection + # location code, everything else to a link to the asset page. Values: + # assetpage | assetnumber | serialnumber | location | custom. + labelencodesdefaults = { + 'machine': 'assetnumber', + 'computer': 'assetpage', + 'printer': 'assetpage', + 'network_device': 'assetpage', + 'measuring_tool': 'location', + } + printingdefaults += [ + { + 'key': f'label_default_encodes_{assettype}', + 'value': value, + 'valuetype': 'string', + 'category': 'printing', + 'description': f'What a {assettype} label encodes by default: assetpage, ' + 'assetnumber, serialnumber' + + (', location' if assettype == 'measuring_tool' else '') + + ', or custom.', + } + for assettype, value in labelencodesdefaults.items() + ] + + # Collector pc-type -> ComputerType mapping is computers-plugin domain; + # the plugin seeds pctypemap_ settings on install. + defaults = sitedefaults + brandingdefaults + printingdefaults + identifierdefaults + searchdefaults + mapdefaults + [ + # ServiceNow ticket links. Each instance points these at its own + # ServiceNow tenant; blank/disabled renders tickets as plain text. + { + 'key': 'servicenow_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'integrations', + 'description': 'Enable ServiceNow ticket recognition and links in search and dashboards' + }, + { + 'key': 'servicenow_search_url', + 'value': ( + 'https://geaerospaceqa.service-now.com/now/nav/ui/search/' + '0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/' + 'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' + ), + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'ServiceNow global-search URL template. {ticket} is the ticket number.' + }, + { + 'key': 'servicenow_ticket_prefixes', + 'value': 'GEINC,GECHG,GERIT,GESCT', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Comma-separated ticket-number prefixes recognized as ServiceNow tickets' + }, + { + 'key': 'servicenow_incident_url', + 'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'ServiceNow incident URL template. {ticket} is the incident number.' + }, + { + 'key': 'servicenow_change_url', + 'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'ServiceNow change-request URL template. {ticket} is the change number.' + }, + # Zabbix integration + { + 'key': 'zabbix_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'integrations', + 'description': 'Enable Zabbix integration for printer supply monitoring' + }, + { + 'key': 'zabbix_url', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)' + }, + { + 'key': 'zabbix_token', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Zabbix API authentication token' + }, + # Dell warranty lookup (Dell TechDirect Warranty API, OAuth2) + { + 'key': 'warranty_dell_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'integrations', + 'description': 'Enable Dell warranty lookups (service-tag entitlements)' + }, + { + 'key': 'warranty_dell_clientid', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Dell TechDirect API client id' + }, + { + 'key': 'warranty_dell_clientsecret', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Dell TechDirect API client secret' + }, + { + 'key': 'warranty_dell_tokenurl', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Dell OAuth token URL (blank = Dell default)' + }, + { + 'key': 'warranty_dell_apiurl', + 'value': '', + 'valuetype': 'string', + 'category': 'integrations', + 'description': 'Dell warranty API URL (blank = Dell default)' + }, + # Email/SMTP settings + { + 'key': 'smtp_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'email', + 'description': 'Enable email notifications and alerts' + }, + { + 'key': 'smtp_host', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'SMTP server hostname' + }, + { + 'key': 'smtp_port', + 'value': '587', + 'valuetype': 'integer', + 'category': 'email', + 'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)' + }, + { + 'key': 'smtp_username', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'SMTP authentication username' + }, + { + 'key': 'smtp_password', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'SMTP authentication password' + }, + { + 'key': 'smtp_use_tls', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'email', + 'description': 'Use TLS encryption for SMTP connection' + }, + { + 'key': 'smtp_from_address', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'From address for outgoing emails' + }, + { + 'key': 'smtp_from_name', + 'value': 'ShopDB', + 'valuetype': 'string', + 'category': 'email', + 'description': 'From name for outgoing emails' + }, + { + 'key': 'alert_recipients', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'Default email recipients for alerts (comma-separated)' + }, + # Audit log settings + { + 'key': 'audit_retention_days', + 'value': '90', + 'valuetype': 'integer', + 'category': 'audit', + 'description': 'Number of days to retain audit logs (0 = keep forever)' + }, + # Authentication settings + { + 'key': 'saml_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'auth', + 'description': 'Enable SAML SSO authentication' + }, + { + 'key': 'saml_idp_metadata_url', + 'value': '', + 'valuetype': 'string', + 'category': 'auth', + 'description': 'SAML Identity Provider metadata URL' + }, + { + 'key': 'saml_entity_id', + 'value': '', + 'valuetype': 'string', + 'category': 'auth', + 'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)' + }, + { + 'key': 'saml_acs_url', + 'value': '', + 'valuetype': 'string', + 'category': 'auth', + 'description': 'SAML Assertion Consumer Service URL' + }, + { + 'key': 'saml_allow_local_login', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'auth', + 'description': 'Allow local username/password login when SAML is enabled' + }, + { + 'key': 'saml_auto_create_users', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'auth', + 'description': 'Automatically create users on first SAML login' + }, + { + 'key': 'saml_admin_group', + 'value': '', + 'valuetype': 'string', + 'category': 'auth', + 'description': 'SAML group name that grants admin role' + }, + ] + + return defaults + + +@settings_bp.route('/seed', methods=['POST']) +@jwt_required() +@require_permission('settings.edit') +def seed_default_settings(): + """Seed default settings if they don't exist.""" + created = 0 + for d in build_default_settings(): + if not Setting.query.filter_by(key=d['key']).first(): + setting = Setting(**d) + db.session.add(setting) + created += 1 + + db.session.commit() + invalidate_settings_cache() + + return success_response({'created': created}, message=f'{created} default settings created') diff --git a/shopdb/core/api/setup.py b/shopdb/core/api/setup.py new file mode 100644 index 00000000..ff748294 --- /dev/null +++ b/shopdb/core/api/setup.py @@ -0,0 +1,114 @@ +"""First-run setup wizard support endpoints. + +The wizard itself is frontend; these endpoints cover the pieces that need +server work: seeding common starter data and marking setup finished. Site, +plugin, and map configuration reuse the existing settings / plugins APIs. +""" + +from flask import Blueprint, request, current_app +from flask_jwt_extended import jwt_required +from werkzeug.security import generate_password_hash + +from shopdb.extensions import db +from shopdb.core.models import Vendor, Setting, User, Role +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_role + +setup_bp = Blueprint('setup', __name__) + +# Common hardware vendors most sites will want on hand. +STARTER_VENDORS = [ + ('Dell Inc.', 'https://www.dell.com'), + ('HP Inc.', 'https://www.hp.com'), + ('Lenovo', 'https://www.lenovo.com'), + ('Xerox', 'https://www.xerox.com'), + ('Zebra Technologies', 'https://www.zebra.com'), + ('Cisco', 'https://www.cisco.com'), + ('Brother', 'https://www.brother.com'), + ('Microsoft', 'https://www.microsoft.com'), +] + + +@setup_bp.route('/needs-admin', methods=['GET']) +def needs_admin(): + """True when no users exist yet - the app's very first run. Public so the + login screen can offer to create the first admin.""" + return success_response({'needsadmin': User.query.count() == 0}) + + +@setup_bp.route('/create-admin', methods=['POST']) +def create_admin(): + """Create the first admin. Only works while there are zero users, so it + can't be abused after setup. No auth (there is no one to auth as yet).""" + if User.query.count() > 0: + return error_response(ErrorCodes.FORBIDDEN, + 'An account already exists; sign in instead.', http_code=403) + data = request.get_json() or {} + username = (data.get('username') or '').strip() + email = (data.get('email') or '').strip() + password = data.get('password') or '' + if not (username and email and password): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'username, email and password are required') + + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: + admin_role = Role(rolename='admin', description='Administrator') + db.session.add(admin_role) + + user = User(username=username, email=email, + passwordhash=generate_password_hash(password), isactive=True) + user.roles.append(admin_role) + db.session.add(user) + db.session.commit() + return success_response({'username': username}, message='Admin created', http_code=201) + + +@setup_bp.route('/seed-reference', methods=['POST']) +@jwt_required() +@require_role('admin') +def seed_reference(): + """Seed core reference data + permissions + default settings. Idempotent - + reuses the same routines as the `flask seed ...` CLI commands.""" + # Invoke the seed CLI commands in a proper click context via the runner. + runner = current_app.test_cli_runner() + for command in ('reference-data', 'permissions', 'settings'): + result = runner.invoke(args=['seed', command]) + if result.exit_code not in (0, None): + return error_response(ErrorCodes.INTERNAL_ERROR, + f'seed {command} failed: {result.output}', http_code=500) + return success_response(message='Reference data, permissions and settings seeded.') + + +@setup_bp.route('/seed-starter', methods=['POST']) +@jwt_required() +@require_role('admin') +def seed_starter(): + """Add common vendors that are not already present. Idempotent.""" + existing = {v.vendor.strip().lower() for v in Vendor.query.all()} + added = [] + for name, website in STARTER_VENDORS: + if name.strip().lower() in existing: + continue + db.session.add(Vendor(vendor=name, website=website)) + added.append(name) + db.session.commit() + return success_response( + {'added': added, 'addedcount': len(added)}, + message=f'Seeded {len(added)} vendor(s).' + ) + + +@setup_bp.route('/complete', methods=['POST']) +@jwt_required() +@require_role('admin') +def mark_complete(): + """Flag the first-run setup as finished.""" + row = Setting.query.filter_by(key='setup_complete').first() + if row: + row.value = 'true' + else: + db.session.add(Setting(key='setup_complete', value='true', + valuetype='boolean', category='site')) + db.session.commit() + return success_response({'complete': True}, message='Setup marked complete.') diff --git a/shopdb/core/api/supportteams.py b/shopdb/core/api/supportteams.py new file mode 100644 index 00000000..918d5cd4 --- /dev/null +++ b/shopdb/core/api/supportteams.py @@ -0,0 +1,219 @@ +"""Support Teams API endpoints - teams and their nested contacts.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ( + SupportTeam, SupportTeamContact, Application, AuditLog +) +from shopdb.utils.responses import ( + success_response, + error_response, + ErrorCodes +) +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +supportteams_bp = Blueprint('supportteams', __name__) + + +@supportteams_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_support_teams(): + """List support teams (with contacts). ?active and ?teamname filters.""" + query = SupportTeam.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(SupportTeam.isactive == True) + + # Exact-match natural-key lookup for idempotent import (team name). + if exactteamname := request.args.get('teamname'): + query = query.filter(SupportTeam.teamname == exactteamname) + + teams = query.order_by(SupportTeam.teamname).all() + return success_response([team.to_dict() for team in teams]) + + +@supportteams_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_support_team(team_id: int): + """Get a single support team with its contacts.""" + team = db.session.get(SupportTeam, team_id) + if not team: + return error_response(ErrorCodes.NOT_FOUND, 'Support team not found', + http_code=404) + return success_response(team.to_dict()) + + +@supportteams_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_support_team(): + """Create a new support team.""" + data = request.get_json() + if not data or not data.get('teamname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'teamname is required') + + if SupportTeam.query.filter_by(teamname=data['teamname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Support team '{data['teamname']}' already exists", + http_code=409) + + team = SupportTeam( + teamname=data['teamname'], + teamurl=data.get('teamurl'), + isactive=data.get('isactive', True)) + + db.session.add(team) + apply_import_timestamps(team, data) + db.session.flush() + + AuditLog.log('created', 'SupportTeam', entityid=team.supportteamid, + entityname=team.teamname) + db.session.commit() + + return success_response(team.to_dict(), message='Support team created', + http_code=201) + + +@supportteams_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_support_team(team_id: int): + """Update a support team.""" + team = db.session.get(SupportTeam, team_id) + if not team: + return error_response(ErrorCodes.NOT_FOUND, 'Support team not found', + http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'teamname' in data and data['teamname'] != team.teamname: + if SupportTeam.query.filter_by(teamname=data['teamname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Support team '{data['teamname']}' already exists", + http_code=409) + + for key in ['teamname', 'teamurl', 'isactive']: + if key in data: + setattr(team, key, data[key]) + + apply_import_timestamps(team, data) + AuditLog.log('updated', 'SupportTeam', entityid=team.supportteamid, + entityname=team.teamname) + db.session.commit() + + return success_response(team.to_dict(), message='Support team updated') + + +@supportteams_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_support_team(team_id: int): + """Delete a support team; 409 while any application references it.""" + team = db.session.get(SupportTeam, team_id) + if not team: + return error_response(ErrorCodes.NOT_FOUND, 'Support team not found', + http_code=404) + + refcount = Application.query.filter_by(supportteamid=team_id).count() + if refcount: + return error_response( + ErrorCodes.CONFLICT, + f'{refcount} application(s) still reference this support team', + http_code=409) + + AuditLog.log('deleted', 'SupportTeam', entityid=team.supportteamid, + entityname=team.teamname) + db.session.delete(team) # cascade removes its contacts + db.session.commit() + + return success_response(message='Support team deleted') + + +# ---- Contacts (nested under a team) ---- + +@supportteams_bp.route('//contacts', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_contact(team_id: int): + """Add a contact to a support team.""" + team = db.session.get(SupportTeam, team_id) + if not team: + return error_response(ErrorCodes.NOT_FOUND, 'Support team not found', + http_code=404) + + data = request.get_json() + if not data or not data.get('name'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + + contact = SupportTeamContact( + supportteamid=team_id, + name=data['name'], + sso=data.get('sso'), + sortorder=data.get('sortorder', 0), + isactive=data.get('isactive', True)) + + db.session.add(contact) + apply_import_timestamps(contact, data) + db.session.flush() + + AuditLog.log('created', 'SupportTeamContact', entityid=contact.contactid, + entityname=contact.name) + db.session.commit() + + return success_response(contact.to_dict(), message='Contact created', + http_code=201) + + +@supportteams_bp.route('//contacts/', + methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_contact(team_id: int, contact_id: int): + """Update a support-team contact.""" + contact = SupportTeamContact.query.filter_by( + contactid=contact_id, supportteamid=team_id).first() + if not contact: + return error_response(ErrorCodes.NOT_FOUND, 'Contact not found', + http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + for key in ['name', 'sso', 'sortorder', 'isactive']: + if key in data: + setattr(contact, key, data[key]) + + apply_import_timestamps(contact, data) + AuditLog.log('updated', 'SupportTeamContact', entityid=contact.contactid, + entityname=contact.name) + db.session.commit() + + return success_response(contact.to_dict(), message='Contact updated') + + +@supportteams_bp.route('//contacts/', + methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_contact(team_id: int, contact_id: int): + """Delete a support-team contact.""" + contact = SupportTeamContact.query.filter_by( + contactid=contact_id, supportteamid=team_id).first() + if not contact: + return error_response(ErrorCodes.NOT_FOUND, 'Contact not found', + http_code=404) + + AuditLog.log('deleted', 'SupportTeamContact', entityid=contact.contactid, + entityname=contact.name) + db.session.delete(contact) + db.session.commit() + + return success_response(message='Contact deleted') diff --git a/shopdb/core/api/users.py b/shopdb/core/api/users.py new file mode 100644 index 00000000..84f9dac1 --- /dev/null +++ b/shopdb/core/api/users.py @@ -0,0 +1,389 @@ +"""User management API routes.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, current_user +from werkzeug.security import generate_password_hash + +from shopdb.extensions import db +from shopdb.core.models import ( + User, Role, Permission, AuditLog, full_permission_catalog) +from shopdb.utils.responses import success_response, error_response, ErrorCodes +from shopdb.utils.authz import require_role + +users_bp = Blueprint('users', __name__) + + +@users_bp.route('', methods=['GET']) +@jwt_required() +@require_role('admin') +def list_users(): + """List all users.""" + users = User.query.order_by(User.username).all() + return success_response([user_to_dict(u) for u in users]) + + +@users_bp.route('/', methods=['GET']) +@jwt_required() +def get_user(userid: int): + """Get a single user.""" + # inline: decorators cannot express admin-or-self + if not current_user.hasrole('admin') and current_user.userid != userid: + return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403) + + user = db.session.get(User, userid) + if not user: + return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) + + return success_response(user_to_dict(user)) + + +@users_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_user(): + """Create a new user.""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required') + + # Validate required fields + if not data.get('username'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required') + if not data.get('email'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required') + if not data.get('password'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required') + + # Check uniqueness + if User.query.filter_by(username=data['username']).first(): + return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409) + if User.query.filter_by(email=data['email']).first(): + return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409) + + # Admin-created accounts are forced to change the password on first login + # unless the admin explicitly opts out. + mustchange = data.get('mustchangepassword', True) + + user = User( + username=data['username'], + email=data['email'], + passwordhash=generate_password_hash(data['password']), + firstname=data.get('firstname'), + lastname=data.get('lastname'), + isactive=data.get('isactive', True), + mustchangepassword=bool(mustchange) + ) + + # Assign roles + role_ids = data.get('roles', []) + if role_ids: + roles = Role.query.filter(Role.roleid.in_(role_ids)).all() + user.roles = roles + + db.session.add(user) + + # Audit log + AuditLog.log('created', 'User', entityname=user.username) + + db.session.commit() + + # Best-effort welcome email. The account exists regardless of mail outcome; + # a failure is surfaced as a warning in the response, never a hard error. + warning = None + if data.get('sendwelcome', True) and user.email: + sent = _send_welcome_email(user, data['password']) + if not sent: + warning = 'User created but the welcome email could not be sent.' + + payload = user_to_dict(user) + if warning: + payload['warning'] = warning + return success_response(payload, message='User created', http_code=201) + + +def _send_welcome_email(user, temp_password): + """Send a new-user welcome email with sign-in details. Returns True on send. + + Best-effort: any failure (including email being disabled) returns False so + the caller can surface a soft warning without failing user creation. + """ + from shopdb.core.api.settings import get_cached_settings + from shopdb.utils.mailer import render_email, send_email + + settings = get_cached_settings() or {} + facility = settings.get('facility_name') or 'ShopDB' + base_url = (settings.get('site_base_url') or '').rstrip('/') + login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page' + + body = ( + f'

An account has been created for you at {facility}.

' + '
' + f'' + f'' + f'' + f'' + '
Username{user.username}
Temporary password{temp_password}
' + f'

Sign in at {login_link}. You will be asked to set a new password ' + 'the first time you log in.

' + ) + html, text = render_email(f'Welcome to {facility}', body) + return send_email(user.email, f'Your {facility} account', html, text=text) + + +@users_bp.route('/', methods=['PUT']) +@jwt_required() +def update_user(userid: int): + """Update a user.""" + # inline: decorators cannot express admin-or-self + if not current_user.hasrole('admin') and current_user.userid != userid: + return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403) + + user = db.session.get(User, userid) + if not user: + return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required') + + changes = {} + + # Update fields + if 'email' in data and data['email'] != user.email: + if User.query.filter(User.email == data['email'], User.userid != userid).first(): + return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409) + changes['email'] = {'old': user.email, 'new': data['email']} + user.email = data['email'] + + if 'firstname' in data: + if data['firstname'] != user.firstname: + changes['firstname'] = {'old': user.firstname, 'new': data['firstname']} + user.firstname = data['firstname'] + + if 'lastname' in data: + if data['lastname'] != user.lastname: + changes['lastname'] = {'old': user.lastname, 'new': data['lastname']} + user.lastname = data['lastname'] + + # Admin-only fields (inline: gates a subset of fields on a shared route) + if current_user.hasrole('admin'): + if 'isactive' in data: + if data['isactive'] != user.isactive: + changes['isactive'] = {'old': user.isactive, 'new': data['isactive']} + user.isactive = data['isactive'] + + if 'roles' in data: + old_roles = [r.rolename for r in user.roles] + roles = Role.query.filter(Role.roleid.in_(data['roles'])).all() + new_roles = [r.rolename for r in roles] + if set(old_roles) != set(new_roles): + changes['roles'] = {'old': old_roles, 'new': new_roles} + user.roles = roles + + # Unlock user + if data.get('unlock'): + user.lockeduntil = None + user.failedlogins = 0 + changes['unlocked'] = {'old': True, 'new': False} + + # Password change + if 'password' in data and data['password']: + user.passwordhash = generate_password_hash(data['password']) + changes['password'] = {'old': '***', 'new': '***'} + + if changes: + AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes) + + db.session.commit() + + return success_response(user_to_dict(user), message='User updated') + + +@users_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_user(userid: int): + """Delete a user.""" + if current_user.userid == userid: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account') + + user = db.session.get(User, userid) + if not user: + return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) + + username = user.username + db.session.delete(user) + + AuditLog.log('deleted', 'User', entityid=userid, entityname=username) + + db.session.commit() + + return success_response(None, message='User deleted') + + +# Permissions endpoints +@users_bp.route('/permissions', methods=['GET']) +@jwt_required() +def list_permissions(): + """List assignable permissions grouped by category. + + Driven by full_permission_catalog() (core plus ENABLED plugins) so a + disabled plugin's permissions drop out of the role grid. Roles assign by + name; the permissionid comes from the seeded Permission row when present. + """ + idbyname = {p.name: p.permissionid for p in Permission.query.all()} + + catalog = full_permission_catalog() + catalog.sort(key=lambda e: (e[2], e[0])) + + grouped = {} + flat = [] + for name, description, category in catalog: + entry = { + 'permissionid': idbyname.get(name), + 'name': name, + 'description': description, + } + grouped.setdefault(category, []).append(entry) + flat.append({**entry, 'category': category}) + + return success_response({'permissions': flat, 'grouped': grouped}) + + +# Roles endpoints +@users_bp.route('/roles', methods=['GET']) +@jwt_required() +def list_roles(): + """List all roles with their permissions.""" + roles = Role.query.order_by(Role.rolename).all() + return success_response([{ + 'roleid': r.roleid, + 'rolename': r.rolename, + 'description': r.description, + 'usercount': r.users.count(), + 'permissions': [p.name for p in r.permissions], + 'isadmin': r.rolename == 'admin' + } for r in roles]) + + +@users_bp.route('/roles', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_role(): + """Create a new role.""" + data = request.get_json() + if not data or not data.get('rolename'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required') + + if Role.query.filter_by(rolename=data['rolename']).first(): + return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409) + + role = Role( + rolename=data['rolename'], + description=data.get('description') + ) + + # Assign permissions + if 'permissions' in data: + perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all() + role.permissions = perms + + db.session.add(role) + + AuditLog.log('created', 'Role', entityname=role.rolename) + + db.session.commit() + + return success_response({ + 'roleid': role.roleid, + 'rolename': role.rolename, + 'description': role.description, + 'permissions': [p.name for p in role.permissions] + }, message='Role created', http_code=201) + + +@users_bp.route('/roles/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_role(roleid: int): + """Update a role.""" + role = db.session.get(Role, roleid) + if not role: + return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404) + + # Cannot modify admin role permissions + if role.rolename == 'admin' and 'permissions' in request.get_json(): + return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions') + + data = request.get_json() + changes = {} + + if 'description' in data: + if data['description'] != role.description: + changes['description'] = {'old': role.description, 'new': data['description']} + role.description = data['description'] + + # Update permissions + if 'permissions' in data and role.rolename != 'admin': + old_perms = [p.name for p in role.permissions] + perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all() + new_perms = [p.name for p in perms] + if set(old_perms) != set(new_perms): + changes['permissions'] = {'old': old_perms, 'new': new_perms} + role.permissions = perms + + if changes: + AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes) + + db.session.commit() + + return success_response({ + 'roleid': role.roleid, + 'rolename': role.rolename, + 'description': role.description, + 'permissions': [p.name for p in role.permissions] + }, message='Role updated') + + +@users_bp.route('/roles/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_role(roleid: int): + """Delete a role.""" + role = db.session.get(Role, roleid) + if not role: + return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404) + + if role.rolename == 'admin': + return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role') + + if role.users.count() > 0: + return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)') + + rolename = role.rolename + db.session.delete(role) + + AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename) + + db.session.commit() + + return success_response(None, message='Role deleted') + + +def user_to_dict(user: User) -> dict: + """Convert user to dict for API response.""" + return { + 'userid': user.userid, + 'username': user.username, + 'email': user.email, + 'firstname': user.firstname, + 'lastname': user.lastname, + 'isactive': user.isactive, + 'islocked': user.islocked, + 'mustchangepassword': bool(user.mustchangepassword), + 'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None, + 'failedlogins': user.failedlogins, + 'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles], + 'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None, + 'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None + } diff --git a/shopdb/core/api/vendors.py b/shopdb/core/api/vendors.py new file mode 100644 index 00000000..6c1104a9 --- /dev/null +++ b/shopdb/core/api/vendors.py @@ -0,0 +1,149 @@ +"""Vendors API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Vendor +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from shopdb.utils.authz import require_role +from shopdb.utils.import_mode import apply_import_timestamps + +vendors_bp = Blueprint('vendors', __name__) + + +@vendors_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_vendors(): + """List all vendors.""" + page, per_page = get_pagination_params(request) + + query = Vendor.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Vendor.isactive == True) + + # Exact-match natural-key lookup for idempotent import (vendor name). + if exactvendor := request.args.get('vendor'): + query = query.filter(Vendor.vendor == exactvendor) + + if search := request.args.get('search'): + query = query.filter(Vendor.vendor.ilike(f'%{search}%')) + + query = query.order_by(Vendor.vendor) + + items, total = paginate_query(query, page, per_page) + data = [v.to_dict() for v in items] + + return paginated_response(data, page, per_page, total) + + +@vendors_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_vendor(vendor_id: int): + """Get a single vendor.""" + v = db.session.get(Vendor, vendor_id) + + if not v: + return error_response( + ErrorCodes.NOT_FOUND, + f'Vendor with ID {vendor_id} not found', + http_code=404 + ) + + return success_response(v.to_dict()) + + +@vendors_bp.route('', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_vendor(): + """Create a new vendor.""" + data = request.get_json() + + if not data or not data.get('vendor'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required') + + if Vendor.query.filter_by(vendor=data['vendor']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Vendor '{data['vendor']}' already exists", + http_code=409 + ) + + v = Vendor( + vendor=data['vendor'], + description=data.get('description'), + website=data.get('website'), + supportphone=data.get('supportphone'), + supportemail=data.get('supportemail'), + notes=data.get('notes') + ) + + db.session.add(v) + apply_import_timestamps(v, data) + db.session.commit() + + return success_response(v.to_dict(), message='Vendor created', http_code=201) + + +@vendors_bp.route('/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_vendor(vendor_id: int): + """Update a vendor.""" + v = db.session.get(Vendor, vendor_id) + + if not v: + return error_response( + ErrorCodes.NOT_FOUND, + f'Vendor with ID {vendor_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'vendor' in data and data['vendor'] != v.vendor: + if Vendor.query.filter_by(vendor=data['vendor']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Vendor '{data['vendor']}' already exists", + http_code=409 + ) + + for key in ['vendor', 'description', 'website', 'supportphone', 'supportemail', 'notes', 'isactive']: + if key in data: + setattr(v, key, data[key]) + + apply_import_timestamps(v, data) + db.session.commit() + return success_response(v.to_dict(), message='Vendor updated') + + +@vendors_bp.route('/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_vendor(vendor_id: int): + """Delete (deactivate) a vendor.""" + v = db.session.get(Vendor, vendor_id) + + if not v: + return error_response( + ErrorCodes.NOT_FOUND, + f'Vendor with ID {vendor_id} not found', + http_code=404 + ) + + v.isactive = False + db.session.commit() + + return success_response(message='Vendor deleted') diff --git a/shopdb/core/models/__init__.py b/shopdb/core/models/__init__.py new file mode 100644 index 00000000..af6bddba --- /dev/null +++ b/shopdb/core/models/__init__.py @@ -0,0 +1,70 @@ +"""Core SQLAlchemy models.""" + +from .base import BaseModel, SoftDeleteMixin, AuditMixin +from .asset import Asset, AssetType, AssetStatus +from .modeltype import ModelType +from .vendor import Vendor +from .model import Model +from .businessunit import BusinessUnit +from .dashboarddefault import DashboardDefault +from .location import Location, LocationType, derive_locationcode +from .operatingsystem import OperatingSystem +from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation +from .communication import Communication, CommunicationType +from .user import User, Role, Permission, full_permission_catalog +from .application import Application, AppVersion +from .supportteam import SupportTeam, SupportTeamContact +from .setting import Setting +from .auditlog import AuditLog +from .customfield import CustomField, CustomFieldValue +from .apitoken import ApiToken + +__all__ = [ + # Base + 'BaseModel', + 'SoftDeleteMixin', + 'AuditMixin', + # Asset (new architecture) + 'Asset', + 'AssetType', + 'AssetStatus', + # Model-type lookup (referenced by models.modeltypeid) + 'ModelType', + # Reference + 'Vendor', + 'Model', + 'BusinessUnit', + 'DashboardDefault', + 'Location', + 'LocationType', + 'derive_locationcode', + 'OperatingSystem', + # Relationships + 'AssetRelationship', + 'RelationshipType', + 'RelationshipTypePropagation', + # Communication + 'Communication', + 'CommunicationType', + # Auth + 'User', + 'Role', + 'Permission', + 'full_permission_catalog', + # Applications + 'Application', + 'AppVersion', + # Support teams + 'SupportTeam', + 'SupportTeamContact', + # Knowledge Base + # Settings + 'Setting', + # Audit + 'AuditLog', + # Custom fields + 'CustomField', + 'CustomFieldValue', + # Personal API tokens + 'ApiToken', +] diff --git a/shopdb/core/models/apitoken.py b/shopdb/core/models/apitoken.py new file mode 100644 index 00000000..86ff5019 --- /dev/null +++ b/shopdb/core/models/apitoken.py @@ -0,0 +1,128 @@ +"""Personal API token model. + +A personal API token (PAT) lets a script or integration authenticate as a +user without the hourly-expiring login JWT. The secret is shown ONCE at +creation; only its sha256 hash is stored. By default the token acts as its +owning user, so the existing role/permission decorators authorize it unchanged. +A token MAY optionally carry a scopes list (see scopes/scopelist): a scoped +token grants only the listed permissions, intersected with what the owner holds, +and suspends the admin bypass. See shopdb/utils/authz.py for the enforcement. +""" + +import hashlib +import json +import secrets +from datetime import datetime, timezone + +from shopdb.extensions import db +from .base import BaseModel + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +# Wire label on the full secret. Scripts send "Authorization: Bearer ". +TOKEN_SECRET_PREFIX = 'shopdb_pat_' +# Hex chars of randomness after the label (secrets.token_hex(20) => 40 hex). +_TOKEN_RANDOM_BYTES = 20 +# How many leading random-hex chars we keep in the clear for display/lookup. +_TOKEN_PREFIX_LEN = 8 + + +class ApiToken(BaseModel): + """Personal API token. Stores only the hash of the secret.""" + __tablename__ = 'apitokens' + + tokenid = db.Column(db.Integer, primary_key=True) + # The token acts as this user; NOT NULL so authz always has a principal. + userid = db.Column(db.Integer, db.ForeignKey('users.userid'), + nullable=False, index=True) + # What the token is for (e.g. "legacy import runner"). + name = db.Column(db.String(100), nullable=False) + # First few random-hex chars, kept clear so a user can tell tokens apart. + tokenprefix = db.Column(db.String(16), nullable=True, index=True) + # sha256 hex of the full secret. Unique so a hash lookup finds one row. + tokenhash = db.Column(db.String(64), unique=True, nullable=False, index=True) + # Null expiresat means the token never expires. + expiresat = db.Column(db.DateTime, nullable=True) + # Last time the token authenticated a request (throttled write). + lastusedat = db.Column(db.DateTime, nullable=True) + # JSON array of permission-name strings. NULL = unscoped (full owner + # authority). A scoped token grants ONLY these, intersected with what the + # owner holds, and suspends the admin bypass. See scopelist below. + scopes = db.Column(db.Text, nullable=True) + + user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic')) + + @staticmethod + def generate_secret() -> str: + """Return a fresh full secret: shopdb_pat_<40 hex>. Never stored.""" + return TOKEN_SECRET_PREFIX + secrets.token_hex(_TOKEN_RANDOM_BYTES) + + @staticmethod + def hash_secret(secret: str) -> str: + """sha256 hex of the full secret. The token has 160 bits of entropy, + so a plain hash lookup (not a slow password hash) is appropriate.""" + return hashlib.sha256(secret.encode('utf-8')).hexdigest() + + @staticmethod + def prefix_of(secret: str) -> str: + """The clear display prefix (leading random-hex chars) of a secret.""" + randompart = secret[len(TOKEN_SECRET_PREFIX):] + return randompart[:_TOKEN_PREFIX_LEN] + + @property + def is_expired(self) -> bool: + """True when expiresat is set and in the past.""" + return self.expiresat is not None and self.expiresat < _utcnow() + + @property + def scopelist(self): + """Parsed scope names as a list, or None when the token is unscoped.""" + if self.scopes is None: + return None + try: + value = json.loads(self.scopes) + except (ValueError, TypeError): + return None + return value if isinstance(value, list) else None + + @scopelist.setter + def scopelist(self, names): + """Store a scope list, or None to clear scoping. Does NOT validate the + names; callers reject bad input first via unknown_scope_names.""" + if names is None: + self.scopes = None + else: + self.scopes = json.dumps(list(names)) + + @staticmethod + def unknown_scope_names(names) -> list: + """Return the subset of names that are not in the permission catalog. + + The catalog is core plus every ENABLED plugin's permissions, so a scope + naming a disabled plugin's permission is treated as unknown.""" + from shopdb.core.models.user import full_permission_catalog + known = {name for name, _desc, _cat in full_permission_catalog()} + return [n for n in names if n not in known] + + def to_dict(self, include_owner: bool = False) -> dict: + """Serialize for the API. NEVER includes the hash or the secret.""" + result = { + 'tokenid': self.tokenid, + 'userid': self.userid, + 'name': self.name, + 'tokenprefix': self.tokenprefix, + 'displayprefix': f'{TOKEN_SECRET_PREFIX}{self.tokenprefix or ""}', + 'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None, + 'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None, + 'scopes': self.scopelist, + 'isactive': self.isactive, + 'isexpired': self.is_expired, + 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, + } + if include_owner: + result['username'] = self.user.username if self.user else None + return result diff --git a/shopdb/core/models/application.py b/shopdb/core/models/application.py new file mode 100644 index 00000000..1ddf72df --- /dev/null +++ b/shopdb/core/models/application.py @@ -0,0 +1,95 @@ +"""Application tracking models.""" + +from datetime import datetime, timezone + +from shopdb.extensions import db +from .base import BaseModel + + +def _utcnow(): + # naive UTC to match the other DB DateTime columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) +# SupportTeam / SupportTeamContact live in supportteam.py; imported by the +# models package so the Application.supportteam relationship resolves. + + +class Application(BaseModel): + """Application catalog.""" + __tablename__ = 'applications' + + appid = db.Column(db.Integer, primary_key=True) + appname = db.Column(db.String(100), unique=True, nullable=False) + appdescription = db.Column(db.String(255)) + supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid')) + isinstallable = db.Column(db.Boolean, default=False) + applicationnotes = db.Column(db.Text) + installpath = db.Column(db.String(255)) + applicationlink = db.Column(db.String(512)) + documentationpath = db.Column(db.String(512)) + ishidden = db.Column(db.Boolean, default=False) + isprinter = db.Column(db.Boolean, default=False) + islicenced = db.Column(db.Boolean, default=False) + isrequired = db.Column( + db.Boolean, default=False, + comment='Required on all PCs (drives the software-compliance report)' + ) + image = db.Column(db.String(255)) + + # Relationships + supportteam = db.relationship('SupportTeam', back_populates='applications') + versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic') + + def to_dict(self): + """Serialize, flattening the support team + its active contacts. + + Emits supportteamname, teamurl, and the team's active contacts so the + frontend needs a single call to render the Support card. + """ + result = super().to_dict() + team = self.supportteam + result['supportteamname'] = team.teamname if team else None + result['teamurl'] = team.teamurl if team else None + result['contacts'] = [ + {'name': c.name, 'sso': c.sso} + for c in team.active_contacts() + ] if team else [] + return result + + def __repr__(self): + return f"" + + +class AppVersion(db.Model): + """Application version tracking.""" + __tablename__ = 'appversions' + + appversionid = db.Column(db.Integer, primary_key=True) + appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False) + version = db.Column(db.String(50), nullable=False) + releasedate = db.Column(db.Date) + notes = db.Column(db.String(255)) + dateadded = db.Column(db.DateTime, default=_utcnow) + isactive = db.Column(db.Boolean, default=True) + + # Relationships + application = db.relationship('Application', back_populates='versions') + + # Unique constraint on app + version + __table_args__ = ( + db.UniqueConstraint('appid', 'version', name='uq_app_version'), + ) + + def to_dict(self): + """Convert to dictionary.""" + return { + 'appversionid': self.appversionid, + 'appid': self.appid, + 'version': self.version, + 'releasedate': self.releasedate.isoformat() if self.releasedate else None, + 'notes': self.notes, + 'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None, + 'isactive': self.isactive + } + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/asset.py b/shopdb/core/models/asset.py new file mode 100644 index 00000000..5f0db55c --- /dev/null +++ b/shopdb/core/models/asset.py @@ -0,0 +1,285 @@ +"""Polymorphic Asset models - core of the new asset architecture.""" + +from shopdb.extensions import db +from .base import BaseModel, SoftDeleteMixin, AuditMixin + + +class AssetType(BaseModel): + """ + Registry of asset categories. + + Each type maps to a plugin-owned extension table. + Examples: machine, computer, network_device, printer + """ + __tablename__ = 'assettypes' + + assettypeid = db.Column(db.Integer, primary_key=True) + assettype = db.Column( + db.String(50), + unique=True, + nullable=False, + comment='Category name: machine, computer, network_device, printer' + ) + pluginname = db.Column( + db.String(100), + nullable=True, + comment='Plugin that owns this type' + ) + tablename = db.Column( + db.String(100), + nullable=True, + comment='Extension table name for this type' + ) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + color = db.Column(db.String(20), comment='CSS color for UI/map markers') + + def __repr__(self): + return f"" + + +class AssetStatus(BaseModel): + """Asset status options.""" + __tablename__ = 'assetstatuses' + + statusid = db.Column(db.Integer, primary_key=True) + status = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for UI') + + def __repr__(self): + return f"" + + +class Asset(BaseModel, SoftDeleteMixin, AuditMixin): + """ + Core asset model - minimal shared fields. + + Category-specific data lives in plugin extension tables + (machines, computers, network_devices, printers). + The assetid matches original machineid for migration compatibility. + """ + __tablename__ = 'assets' + + assetid = db.Column(db.Integer, primary_key=True) + + # Identification + assetnumber = db.Column( + db.String(50), + unique=True, + nullable=False, + index=True, + comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)' + ) + name = db.Column( + db.String(100), + comment='Display name/alias' + ) + gaugelabreference = db.Column( + db.String(50), + index=True, + comment='Gauge lab asset reference (authoritative tag the gauge lab ' + 'assigns to machines); distinct from assetnumber' + ) + maintenancereference = db.Column( + db.String(50), + index=True, + comment='Maintenance system asset reference; distinct from assetnumber' + ) + serialnumber = db.Column( + db.String(100), + index=True, + comment='Hardware serial number' + ) + + # Classification + assettypeid = db.Column( + db.Integer, + db.ForeignKey('assettypes.assettypeid'), + nullable=False + ) + statusid = db.Column( + db.Integer, + db.ForeignKey('assetstatuses.statusid'), + default=1, + comment='In Use, Spare, Retired, etc.' + ) + + # Location and organization + locationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True + ) + businessunitid = db.Column( + db.Integer, + db.ForeignKey('businessunits.businessunitid'), + nullable=True + ) + + # Floor map position (ADR-001: asset-specific override; nullable) + mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)') + mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)') + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Relationships + assettype = db.relationship('AssetType', backref='assets') + status = db.relationship('AssetStatus', backref='assets') + location = db.relationship('Location', backref='assets') + businessunit = db.relationship('BusinessUnit', backref='assets') + + # Communications (one-to-many) - will be migrated to use assetid + communications = db.relationship( + 'Communication', + foreign_keys='Communication.assetid', + backref='asset', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + # Indexes + __table_args__ = ( + db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'), + db.Index('idx_asset_location', 'locationid'), + db.Index('idx_asset_active', 'isactive'), + db.Index('idx_asset_status', 'statusid'), + ) + + def __repr__(self): + return f"" + + @property + def display_name(self): + """Get display name (name if set, otherwise assetnumber).""" + return self.name or self.assetnumber + + @property + def primary_ip(self): + """Get primary IP address from communications.""" + comm = self.communications.filter_by( + isprimary=True, + comtypeid=1 # IP type + ).first() + if comm: + return comm.ipaddress + # Fall back to any IP + comm = self.communications.filter_by(comtypeid=1).first() + return comm.ipaddress if comm else None + + def get_inherited_location(self): + """ + Get location data from a related asset if this asset has none. + + Returns dict with locationid, location_name, mapx, mapy, and + inherited_from (assetnumber of source asset) if location was inherited. + Returns None if no location data available. + """ + if self.locationid is not None or (self.mapx is not None and self.mapy is not None): + return None + + related_assets = [] + + if hasattr(self, 'incoming_relationships'): + for rel in self.incoming_relationships: + if rel.sourceasset and rel.isactive: + related_assets.append(rel.sourceasset) + + if hasattr(self, 'outgoing_relationships'): + for rel in self.outgoing_relationships: + if rel.targetasset and rel.isactive: + related_assets.append(rel.targetasset) + + for related in related_assets: + if related.locationid is not None or (related.mapx is not None and related.mapy is not None): + return { + 'locationid': related.locationid, + 'locationname': related.location.locationname if related.location else None, + 'mapx': related.mapx, + 'mapy': related.mapy, + 'inheritedfrom': related.assetnumber + } + + return None + + def to_dict(self, include_type_data=False, include_inherited_location=True): + """ + Convert model to dictionary. + + Args: + include_type_data: If True, include category-specific data from extension table + include_inherited_location: If True, include location from related assets when missing + """ + result = super().to_dict() + + # Add related object names for convenience + if self.assettype: + result['assettypename'] = self.assettype.assettype + result['assettypecolor'] = getattr(self.assettype, 'color', None) + if self.status: + result['statusname'] = self.status.status + result['statuscolor'] = self.status.color + if self.location: + result['locationname'] = self.location.locationname + if self.businessunit: + result['businessunitname'] = self.businessunit.businessunit + + # Add plugin-specific ID for navigation purposes + if hasattr(self, 'machine') and self.machine: + result['pluginid'] = self.machine.machineid + elif hasattr(self, 'computer') and self.computer: + result['pluginid'] = self.computer.computerid + elif hasattr(self, 'network_device') and self.network_device: + result['pluginid'] = self.network_device.networkdeviceid + elif hasattr(self, 'printer') and self.printer: + result['pluginid'] = self.printer.printerid + elif hasattr(self, 'measuringtool') and self.measuringtool: + result['pluginid'] = self.measuringtool.measuringtoolid + + # Include inherited location if this asset has no location data + if include_inherited_location: + inherited = self.get_inherited_location() + if inherited: + result['inheritedlocation'] = inherited + # Also set the location fields if they're missing + if result.get('locationid') is None: + result['locationid'] = inherited['locationid'] + result['locationname'] = inherited['locationname'] + if result.get('mapx') is None: + result['mapx'] = inherited['mapx'] + if result.get('mapy') is None: + result['mapy'] = inherited['mapy'] + + # Operation/short code of the resolved location (own or inherited). + # Derived from the location name's leading token; labels can encode a + # tool's inspection operation instead of the tool. None when unplaced. + from .location import derive_locationcode + result['locationcode'] = derive_locationcode(result.get('locationname')) + + # Include extension data if requested + if include_type_data: + ext_data = self._get_extension_data() + if ext_data: + result['typedata'] = ext_data + + return result + + def _get_extension_data(self): + """Get category-specific data from extension table.""" + # Check for machine extension + if hasattr(self, 'machine') and self.machine: + return self.machine.to_dict() + # Check for computer extension + if hasattr(self, 'computer') and self.computer: + return self.computer.to_dict() + # Check for network_device extension + if hasattr(self, 'network_device') and self.network_device: + return self.network_device.to_dict() + # Check for printer extension + if hasattr(self, 'printer') and self.printer: + return self.printer.to_dict() + # Check for measuring-tool extension + if hasattr(self, 'measuringtool') and self.measuringtool: + return self.measuringtool.to_dict() + return None diff --git a/shopdb/core/models/auditlog.py b/shopdb/core/models/auditlog.py new file mode 100644 index 00000000..7e7896c7 --- /dev/null +++ b/shopdb/core/models/auditlog.py @@ -0,0 +1,160 @@ +"""Audit log model for tracking changes.""" + +from datetime import datetime, timezone +from shopdb.extensions import db + +def _utcnow(): + # naive UTC for DB columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class AuditLog(db.Model): + """ + Audit log for tracking user actions. + + Records who did what, when, from where, and what changed. + """ + __tablename__ = 'auditlogs' + + auditlogid = db.Column(db.Integer, primary_key=True) + + # Who + userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True) + username = db.Column(db.String(100), nullable=True) # Denormalized for history + + # When + timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False, index=True) + + # Where (client info) + ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length + useragent = db.Column(db.String(255), nullable=True) + + # What + action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted + entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc. + entityid = db.Column(db.Integer, nullable=True) # ID of the affected record + entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier + + # Changes (stored as JSON text) + _changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...} + + @property + def changes(self): + """Get changes as dict.""" + if self._changes: + import json + try: + return json.loads(self._changes) + except (json.JSONDecodeError, TypeError): + return None + return None + + @changes.setter + def changes(self, value): + """Set changes from dict.""" + if value is not None: + import json + self._changes = json.dumps(value) + else: + self._changes = None + + # Additional context + details = db.Column(db.Text, nullable=True) # Optional description + + # Relationship + user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic')) + + def to_dict(self): + return { + 'auditlogid': self.auditlogid, + 'userid': self.userid, + 'username': self.username, + 'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None, + 'ipaddress': self.ipaddress, + 'useragent': self.useragent, + 'action': self.action, + 'entitytype': self.entitytype, + 'entityid': self.entityid, + 'entityname': self.entityname, + 'changes': self.changes, + 'details': self.details + } + + @classmethod + def log(cls, action: str, entitytype: str, entityid: int = None, + entityname: str = None, changes: dict = None, details: str = None, + user=None, request=None): + """ + Create an audit log entry. + + Args: + action: 'created', 'updated', 'deleted' + entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting') + entityid: ID of the affected record + entityname: Human-readable name/identifier + changes: Dict of field changes {"field": {"old": x, "new": y}} + details: Optional description + user: Current user object (or will try to get from flask-jwt-extended) + request: Flask request object (or will try to get current request) + """ + from flask import request as flask_request + from flask_jwt_extended import current_user, verify_jwt_in_request + + # Get user info + if user is None: + try: + verify_jwt_in_request(optional=True) + user = current_user + except: + pass + + userid = user.userid if user else None + username = user.username if user else None + + # Get request info + request_obj = request or flask_request + ipaddress = None + useragent = None + + if request_obj: + # Handle proxy forwarding + ipaddress = request_obj.headers.get('X-Forwarded-For', request_obj.remote_addr) + if ipaddress and ',' in ipaddress: + ipaddress = ipaddress.split(',')[0].strip() + useragent = request_obj.headers.get('User-Agent', '')[:255] + + entry = cls( + userid=userid, + username=username, + ipaddress=ipaddress, + useragent=useragent, + action=action, + entitytype=entitytype, + entityid=entityid, + entityname=entityname, + changes=changes, + details=details + ) + + db.session.add(entry) + # Don't commit here - let the caller handle transaction + return entry + + @classmethod + def log_create(cls, entitytype: str, entity, name_field: str = 'name'): + """Log a create action.""" + entityname = getattr(entity, name_field, None) or str(entity) + entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None) + return cls.log('created', entitytype, entityid=entityid, entityname=entityname) + + @classmethod + def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'): + """Log an update action with changes.""" + entityname = getattr(entity, name_field, None) or str(entity) + entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None) + return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes) + + @classmethod + def log_delete(cls, entitytype: str, entityid: int, entityname: str = None): + """Log a delete action.""" + return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname) diff --git a/shopdb/core/models/base.py b/shopdb/core/models/base.py new file mode 100644 index 00000000..9349418a --- /dev/null +++ b/shopdb/core/models/base.py @@ -0,0 +1,71 @@ +"""Base model class with common fields.""" + +from datetime import datetime, timezone +from shopdb.extensions import db + + +def _utcnow(): + # naive UTC for DB columns (stored without tzinfo) + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class BaseModel(db.Model): + """ + Abstract base model with common fields. + All models should inherit from this. + """ + __abstract__ = True + + createddate = db.Column( + db.DateTime, + default=_utcnow, + nullable=False + ) + modifieddate = db.Column( + db.DateTime, + default=_utcnow, + onupdate=_utcnow, + nullable=False + ) + isactive = db.Column(db.Boolean, default=True, nullable=False) + + def to_dict(self): + """Convert model to dictionary.""" + result = {} + for c in self.__table__.columns: + value = getattr(self, c.name) + if isinstance(value, datetime): + value = value.isoformat() + 'Z' + result[c.name] = value + return result + + def update(self, **kwargs): + """Update model attributes.""" + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + @classmethod + def get_active(cls): + """Return query for active records only.""" + return cls.query.filter_by(isactive=True) + + +class SoftDeleteMixin: + """Mixin for soft delete functionality.""" + + deleteddate = db.Column(db.DateTime, nullable=True) + deletedby = db.Column(db.String(100), nullable=True) + + def soft_delete(self, deleted_by: str = None): + """Mark record as deleted.""" + self.isactive = False + self.deleteddate = datetime.now(timezone.utc).replace(tzinfo=None) + self.deletedby = deleted_by + + +class AuditMixin: + """Mixin for audit fields.""" + + createdby = db.Column(db.String(100), nullable=True) + modifiedby = db.Column(db.String(100), nullable=True) diff --git a/shopdb/core/models/businessunit.py b/shopdb/core/models/businessunit.py new file mode 100644 index 00000000..3f97163c --- /dev/null +++ b/shopdb/core/models/businessunit.py @@ -0,0 +1,31 @@ +"""Business Unit model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class BusinessUnit(BaseModel): + """Business unit / department model.""" + __tablename__ = 'businessunits' + + businessunitid = db.Column(db.Integer, primary_key=True) + businessunit = db.Column(db.String(100), unique=True, nullable=False) + code = db.Column(db.String(20), unique=True, comment='Short code') + description = db.Column(db.Text) + + # Optional parent for hierarchy + parentid = db.Column( + db.Integer, + db.ForeignKey('businessunits.businessunitid'), + nullable=True + ) + + # Self-referential relationship for hierarchy + parent = db.relationship( + 'BusinessUnit', + remote_side=[businessunitid], + backref='children' + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/communication.py b/shopdb/core/models/communication.py new file mode 100644 index 00000000..5254d8b0 --- /dev/null +++ b/shopdb/core/models/communication.py @@ -0,0 +1,93 @@ +"""Communication/network interface models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class CommunicationType(BaseModel): + """Types of communication interfaces.""" + __tablename__ = 'communicationtypes' + + comtypeid = db.Column(db.Integer, primary_key=True) + comtype = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + + # Types: IP, Serial, USB, VNC, FTP, DNC, Parallel, Network Interface + + def __repr__(self): + return f"" + + +class Communication(BaseModel): + """ + Communication interface for an asset (or legacy machine). + Stores network config, serial settings, etc. + """ + __tablename__ = 'communications' + + communicationid = db.Column(db.Integer, primary_key=True) + + # New asset-based FK (preferred) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid'), + nullable=True, + comment='FK to assets table (new architecture)' + ) + + comtypeid = db.Column( + db.Integer, + db.ForeignKey('communicationtypes.comtypeid'), + nullable=False + ) + + # Network configuration (for IP type) + ipaddress = db.Column(db.String(50)) + subnetmask = db.Column(db.String(50)) + gateway = db.Column(db.String(50)) + dns1 = db.Column(db.String(50)) + dns2 = db.Column(db.String(50)) + macaddress = db.Column(db.String(50)) + isdhcp = db.Column(db.Boolean, default=False) + + # Serial configuration (for Serial type) + comport = db.Column(db.String(20)) + baudrate = db.Column(db.Integer) + databits = db.Column(db.Integer) + stopbits = db.Column(db.String(10)) + parity = db.Column(db.String(20)) + flowcontrol = db.Column(db.String(20)) + + # VNC/FTP configuration + port = db.Column(db.Integer) + username = db.Column(db.String(100)) + # Note: passwords should not be stored here - use secure vault + + # DNC configuration + pathname = db.Column(db.String(255)) + pathname2 = db.Column(db.String(255), comment='Secondary path for dualpath') + + # Flags + isprimary = db.Column( + db.Boolean, + default=False, + comment='Primary communication method' + ) + ismachinenetwork = db.Column( + db.Boolean, + default=False, + comment='On machine network vs office network' + ) + + notes = db.Column(db.Text) + + # Relationships + comtype = db.relationship('CommunicationType', backref='communications') + + __table_args__ = ( + db.Index('idx_comm_asset', 'assetid'), + db.Index('idx_comm_ip', 'ipaddress'), + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/customfield.py b/shopdb/core/models/customfield.py new file mode 100644 index 00000000..afb39ac1 --- /dev/null +++ b/shopdb/core/models/customfield.py @@ -0,0 +1,94 @@ +"""Custom fields: site-defined extra attributes per asset type. + +A CustomField is a definition scoped to one asset type (machine, computer, +printer, network_device). A CustomFieldValue holds one asset's value for one +field. This is the generic form of the built-in identifier columns - sites add +their own attributes without a schema change. +""" + +from shopdb.extensions import db + +# Allowed datatypes for a custom field. Values are always stored as text and +# cast on render/input by the frontend. +CUSTOM_FIELD_DATATYPES = ('text', 'number', 'date', 'boolean', 'select') + + +class CustomField(db.Model): + __tablename__ = 'customfields' + + fieldid = db.Column(db.Integer, primary_key=True) + assettypeid = db.Column( + db.Integer, + db.ForeignKey('assettypes.assettypeid'), + nullable=False, + comment='Which asset category this field applies to' + ) + # Machine-name key, unique per asset type. Used for stable references. + fieldkey = db.Column(db.String(50), nullable=False) + label = db.Column(db.String(150), nullable=False) + datatype = db.Column(db.String(20), nullable=False, server_default='text') + # JSON array of options, only meaningful when datatype='select' + options = db.Column(db.Text) + showondetail = db.Column(db.Boolean, nullable=False, server_default='1') + showonform = db.Column(db.Boolean, nullable=False, server_default='1') + sortorder = db.Column(db.Integer, nullable=False, server_default='0') + isactive = db.Column(db.Boolean, nullable=False, server_default='1') + # When true, this field's values are matched by global search. + searchable = db.Column(db.Boolean, nullable=False, server_default='0') + + __table_args__ = ( + db.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'), + ) + + def to_dict(self): + import json + parsed_options = [] + if self.options: + try: + parsed_options = json.loads(self.options) + except (ValueError, TypeError): + parsed_options = [] + return { + 'fieldid': self.fieldid, + 'assettypeid': self.assettypeid, + 'fieldkey': self.fieldkey, + 'label': self.label, + 'datatype': self.datatype, + 'options': parsed_options, + 'showondetail': bool(self.showondetail), + 'showonform': bool(self.showonform), + 'sortorder': self.sortorder, + 'isactive': bool(self.isactive), + 'searchable': bool(self.searchable), + } + + +class CustomFieldValue(db.Model): + __tablename__ = 'customfieldvalues' + + valueid = db.Column(db.Integer, primary_key=True) + fieldid = db.Column( + db.Integer, + db.ForeignKey('customfields.fieldid', ondelete='CASCADE'), + nullable=False + ) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False + ) + value = db.Column(db.Text) + + field = db.relationship('CustomField') + + __table_args__ = ( + db.UniqueConstraint('fieldid', 'assetid', name='uq_customfieldvalue_field_asset'), + ) + + def to_dict(self): + return { + 'valueid': self.valueid, + 'fieldid': self.fieldid, + 'assetid': self.assetid, + 'value': self.value, + } diff --git a/shopdb/core/models/dashboarddefault.py b/shopdb/core/models/dashboarddefault.py new file mode 100644 index 00000000..951e41b5 --- /dev/null +++ b/shopdb/core/models/dashboarddefault.py @@ -0,0 +1,28 @@ +"""Dashboard default model: visitor-IP -> business-unit mapping. + +A shopfloor / lobby kiosk display resolves which business unit to show by the +display PC's IP address. Powers the visitor-location lookup the shopfloor and +TV dashboards call when no explicit business unit is given. +""" + +from shopdb.extensions import db +from .base import BaseModel + + +class DashboardDefault(BaseModel): + """Maps a display PC IP address to the business unit it should show.""" + __tablename__ = 'dashboarddefaults' + + dashboarddefaultid = db.Column(db.Integer, primary_key=True) + ipaddress = db.Column(db.String(50), unique=True, nullable=False) + businessunitid = db.Column( + db.Integer, + db.ForeignKey('businessunits.businessunitid'), + nullable=False + ) + description = db.Column(db.String(255)) + + businessunit = db.relationship('BusinessUnit') + + def __repr__(self): + return f" {self.businessunitid}>" diff --git a/shopdb/core/models/location.py b/shopdb/core/models/location.py new file mode 100644 index 00000000..1453b589 --- /dev/null +++ b/shopdb/core/models/location.py @@ -0,0 +1,91 @@ +"""Location + LocationType models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +def derive_locationcode(locationname): + """Operation/short code for a location = leading token of its name. + + Locations have no dedicated code column, so the operation code is the + leading whitespace-delimited token of the location name. Example: + '0615 Blisk Inspection' -> '0615'. Blank/None name -> None. Used by + printed labels that encode a tool's inspection operation rather than the + tool itself. + """ + if not locationname: + return None + parts = str(locationname).strip().split() + return parts[0] if parts else None + + +class LocationType(BaseModel): + """Location classification (ADR-001 shared reference data). + + Seeded values: section, cell, subcell, operation, meetingroom, lab, + office, storage, hallway, networkcloset, building. Sites can extend. + """ + __tablename__ = 'locationtypes' + + locationtypeid = db.Column(db.Integer, primary_key=True) + locationtype = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for UI') + + def __repr__(self): + return f"" + + +class Location(BaseModel): + """Physical location model.""" + __tablename__ = 'locations' + + locationid = db.Column(db.Integer, primary_key=True) + locationname = db.Column(db.String(100), unique=True, nullable=False) + building = db.Column(db.String(100)) + floor = db.Column(db.String(50)) + room = db.Column(db.String(50)) + description = db.Column(db.Text) + + # Classification + tree (ADR-001) + locationtypeid = db.Column( + db.Integer, + db.ForeignKey('locationtypes.locationtypeid'), + nullable=True + ) + parentlocationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True, + comment='Parent location for the site location tree' + ) + + # Map configuration + mapimage = db.Column(db.String(500), comment='Path to floor map image') + mapwidth = db.Column(db.Integer) + mapheight = db.Column(db.Integer) + + # Location-level position used as fallback when an asset has no own coords + # and no inheritsposition relationship resolves. ADR-001 position resolution + # chain priority 3. + mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location') + mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location') + + # Relationships + locationtype = db.relationship('LocationType') + parent = db.relationship('Location', remote_side=[locationid]) + + @property + def locationcode(self): + """Derived operation/short code (leading token of the name).""" + return derive_locationcode(self.locationname) + + def to_dict(self): + data = super().to_dict() + data['locationtypename'] = self.locationtype.locationtype if self.locationtype else None + data['parentlocationname'] = self.parent.locationname if self.parent else None + data['locationcode'] = self.locationcode + return data + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/model.py b/shopdb/core/models/model.py new file mode 100644 index 00000000..130eb7c4 --- /dev/null +++ b/shopdb/core/models/model.py @@ -0,0 +1,43 @@ +"""Model (vendor catalog model number) model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class Model(BaseModel): + """Vendor catalog model information (machines, PCs, printers, network).""" + __tablename__ = 'models' + + modelnumberid = db.Column(db.Integer, primary_key=True) + modelnumber = db.Column(db.String(100), nullable=False) + + # Link to model type (what kind of thing this catalog model is for) + modeltypeid = db.Column( + db.Integer, + db.ForeignKey('modeltypes.modeltypeid'), + nullable=True + ) + + # Link to vendor/manufacturer + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + + description = db.Column(db.Text) + imageurl = db.Column(db.String(500), comment='URL to product image') + documentationurl = db.Column(db.String(500), comment='URL to documentation') + notes = db.Column(db.Text) + + # Relationships + modeltype = db.relationship('ModelType', backref='models') + vendor = db.relationship('Vendor', backref='models') + + # Unique constraint on modelnumber + vendor + __table_args__ = ( + db.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor'), + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/modeltype.py b/shopdb/core/models/modeltype.py new file mode 100644 index 00000000..51ecad35 --- /dev/null +++ b/shopdb/core/models/modeltype.py @@ -0,0 +1,33 @@ +"""Model-type lookup (types the vendor MODELS catalog). + +The Machine instance model and its PC/status lookups were retired (ADR-001); +assets are the platform contract. ModelType is kept because the shared `models` +table references it via models.modeltypeid: it types vendor models (Lathe, +Switch, Laser Printer), not asset instances. Renamed from MachineType to be +role-accurate and to free the "machinetype" name for the machines plugin. +""" + +from shopdb.extensions import db +from .base import BaseModel + + +class ModelType(BaseModel): + """ + Model-type classification (what kind of thing a catalog model is for). + Categories: Equipment, PC, Network, Printer + """ + __tablename__ = 'modeltypes' + + modeltypeid = db.Column(db.Integer, primary_key=True) + modeltype = db.Column(db.String(100), unique=True, nullable=False) + category = db.Column( + db.String(50), + nullable=False, + default='Equipment', + comment='Equipment, PC, Network, or Printer' + ) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/operatingsystem.py b/shopdb/core/models/operatingsystem.py new file mode 100644 index 00000000..7bb851f1 --- /dev/null +++ b/shopdb/core/models/operatingsystem.py @@ -0,0 +1,22 @@ +"""Operating System model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class OperatingSystem(BaseModel): + """Operating system model.""" + __tablename__ = 'operatingsystems' + + osid = db.Column(db.Integer, primary_key=True) + osname = db.Column(db.String(100), nullable=False) + osversion = db.Column(db.String(50)) + architecture = db.Column(db.String(20), comment='x86, x64, ARM') + endoflife = db.Column(db.Date, comment='End of support date') + + __table_args__ = ( + db.UniqueConstraint('osname', 'osversion', name='uq_os_name_version'), + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/relationship.py b/shopdb/core/models/relationship.py new file mode 100644 index 00000000..9e81e6ac --- /dev/null +++ b/shopdb/core/models/relationship.py @@ -0,0 +1,166 @@ +"""Machine and Asset relationship models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class RelationshipType(BaseModel): + """ + Types of relationships between assets. + + ADR-001 seeds three canonical types: partof, controls, connectedto. + Sites may add legacy/communication-flavored types (Serial Cable, Direct + Ethernet, USB, WiFi, Dualpath) for backward compatibility with pre-1.0 + data, but new ADR-001 code paths only reason about the three canonical + types via free-text label for nuance. + """ + __tablename__ = 'relationshiptypes' + + relationshiptypeid = db.Column(db.Integer, primary_key=True) + relationshiptype = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for relationship badges') + + # True: edge has a source->target meaning (controls, partof, Backup For). + # False: symmetric link (Dualpath, connectedto, USB...) shown on the card + # once per peer with no direction, both stored direction rows collapsed. + isdirectional = db.Column( + db.Boolean, + default=True, + nullable=False, + server_default='1', + comment='False = symmetric connection, shown direction-blind' + ) + + # Sibling propagation (ADR-001), WIRED. When a relationship of this type is + # created, the create path fans it out across every through-type in this + # type's propagation set (relationshiptypepropagations rows). For each + # SYMMETRIC through-type P, the source's edge to the target is mirrored to + # every asset linked to the target via P. This is M:N so one type can + # propagate through several rails at once. Seeded rows on this DB: + # controls -> partof (declared; directional rail, NOT consumed yet - + # parent/child fan-out direction is ambiguous) + # controls -> Dualpath (consumed; a dual-bay pair is one physical machine + # with one controller, so both bays carry controls) + # A dual-bay Dualpath pair thus gets the PC's controls link on both bays. + # propagatesthrough is the list of through-type RelationshipType rows. + propagatesthrough = db.relationship( + 'RelationshipType', + secondary='relationshiptypepropagations', + primaryjoin='RelationshipType.relationshiptypeid' + ' == RelationshipTypePropagation.relationshiptypeid', + secondaryjoin='RelationshipType.relationshiptypeid' + ' == RelationshipTypePropagation.throughtypeid', + viewonly=True, + ) + + def __repr__(self): + return f"" + + +class RelationshipTypePropagation(db.Model): + """ + M:N propagation rails (ADR-001). One row means: a relationship of type + relationshiptypeid propagates through the connections of type throughtypeid. + + Replaces the old single-valued relationshiptypes.propagatesthroughid so a + type (controls) can propagate through several through-types (partof AND + Dualpath) at once. Seed/CLI-managed; no management UI. + """ + __tablename__ = 'relationshiptypepropagations' + + propagationid = db.Column(db.Integer, primary_key=True) + relationshiptypeid = db.Column( + db.Integer, + db.ForeignKey('relationshiptypes.relationshiptypeid'), + nullable=False + ) + throughtypeid = db.Column( + db.Integer, + db.ForeignKey('relationshiptypes.relationshiptypeid'), + nullable=False + ) + + __table_args__ = ( + db.UniqueConstraint( + 'relationshiptypeid', + 'throughtypeid', + name='uq_reltype_propagation' + ), + ) + + def __repr__(self): + return f"" + + +class AssetRelationship(BaseModel): + """ + Relationships between assets. + + Examples: + - Computer controls Machine + - Two machines are dualpath partners + - Network device connects to machine + """ + __tablename__ = 'assetrelationships' + + relationshipid = db.Column(db.Integer, primary_key=True) + + sourceassetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid'), + nullable=False + ) + targetassetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid'), + nullable=False + ) + relationshiptypeid = db.Column( + db.Integer, + db.ForeignKey('relationshiptypes.relationshiptypeid'), + nullable=False + ) + + # Free-text description carrying domain nuance ("DNC feed", + # "operator workstation", "ethernet PoE"). Avoids inflating type list. + label = db.Column(db.String(200), comment='Free-text relationship description (ADR-001)') + + # When true, resolve_asset_position walks across this edge (priority 2 + # in the resolution chain). Defaults to true for partof + controls when + # the relationship is created via the API; nullable for legacy rows. + inheritsposition = db.Column( + db.Boolean, + default=True, + nullable=False, + server_default='1', + comment='If true, resolved-position walk follows this edge (ADR-001)' + ) + + notes = db.Column(db.Text) + + sourceasset = db.relationship( + 'Asset', + foreign_keys=[sourceassetid], + backref='outgoing_relationships' + ) + targetasset = db.relationship( + 'Asset', + foreign_keys=[targetassetid], + backref='incoming_relationships' + ) + relationshiptype = db.relationship('RelationshipType', backref='asset_relationships') + + __table_args__ = ( + db.UniqueConstraint( + 'sourceassetid', + 'targetassetid', + 'relationshiptypeid', + name='uq_asset_relationship' + ), + db.Index('idx_asset_rel_source', 'sourceassetid'), + db.Index('idx_asset_rel_target', 'targetassetid'), + ) + + def __repr__(self): + return f" {self.targetassetid}>" diff --git a/shopdb/core/models/setting.py b/shopdb/core/models/setting.py new file mode 100644 index 00000000..8c82c1fa --- /dev/null +++ b/shopdb/core/models/setting.py @@ -0,0 +1,102 @@ +"""System settings model for key-value configuration storage.""" + +from datetime import datetime, timezone + +from sqlalchemy.exc import IntegrityError + +from shopdb.extensions import db + + +def _utcnow(): + """Naive UTC now for column defaults (matches the app's naive datetime cols).""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class Setting(db.Model): + """ + Key-value store for system settings. + + Settings can be managed via the admin UI and are cached + for performance. + """ + __tablename__ = 'settings' + + settingid = db.Column(db.Integer, primary_key=True, autoincrement=True) + key = db.Column(db.String(100), unique=True, nullable=False, index=True) + value = db.Column(db.Text, nullable=True) + valuetype = db.Column(db.String(20), default='string') # string, boolean, integer, json + category = db.Column(db.String(50), default='general') # For grouping in UI + description = db.Column(db.String(255), nullable=True) + createddate = db.Column(db.DateTime, default=_utcnow) + modifieddate = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow) + + def to_dict(self): + return { + 'settingid': self.settingid, + 'key': self.key, + 'value': self.get_typed_value(), + 'valuetype': self.valuetype, + 'category': self.category, + 'description': self.description, + 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, + 'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None, + } + + def get_typed_value(self): + """Return value converted to its proper type.""" + if self.value is None: + return None + if self.valuetype == 'boolean': + return self.value.lower() in ('true', '1', 'yes') + if self.valuetype == 'integer': + try: + return int(self.value) + except (ValueError, TypeError): + return 0 + return self.value + + @classmethod + def get(cls, key: str, default=None): + """Get a setting value by key.""" + setting = cls.query.filter_by(key=key).first() + if setting: + return setting.get_typed_value() + return default + + @staticmethod + def _stringify(value): + """Convert a value to its stored string form.""" + if isinstance(value, bool): + return 'true' if value else 'false' + return str(value) if value is not None else None + + @classmethod + def set(cls, key: str, value, valuetype: str = 'string', category: str = 'general', description: str = None): + """Set a setting value, creating if it doesn't exist. + + Handles the create race: two concurrent callers can both find no row and + both try to INSERT the same unique key. The loser's commit raises + IntegrityError; we roll back, re-fetch the row the winner created, and + apply our value to it. + """ + setting = cls.query.filter_by(key=key).first() + if setting: + setting.value = cls._stringify(value) + db.session.commit() + return setting + + setting = cls(key=key, valuetype=valuetype, category=category, + description=description, value=cls._stringify(value)) + db.session.add(setting) + try: + db.session.commit() + return setting + except IntegrityError: + db.session.rollback() + # Another transaction inserted this key first; update that row. + setting = cls.query.filter_by(key=key).first() + if setting is None: + raise + setting.value = cls._stringify(value) + db.session.commit() + return setting diff --git a/shopdb/core/models/supportteam.py b/shopdb/core/models/supportteam.py new file mode 100644 index 00000000..2730a578 --- /dev/null +++ b/shopdb/core/models/supportteam.py @@ -0,0 +1,61 @@ +"""Application support teams and their contacts. + +A support team is who you contact about an application; each team carries an +optional ServiceNow group deep link (teamurl) and a list of named contacts +(the people you actually reach out to, legacy called them "app owners"). +Applications point at one team via applications.supportteamid. +""" + +from shopdb.extensions import db +from .base import BaseModel + + +class SupportTeam(BaseModel): + """A support team an application belongs to.""" + __tablename__ = 'supportteams' + + supportteamid = db.Column(db.Integer, primary_key=True) + teamname = db.Column(db.String(100), unique=True, nullable=False) + teamurl = db.Column(db.Text) # ServiceNow group deep link, nullable + + # Contacts cascade-delete with the team. + contacts = db.relationship( + 'SupportTeamContact', back_populates='team', + cascade='all, delete-orphan', lazy='select') + applications = db.relationship( + 'Application', back_populates='supportteam', lazy='dynamic') + + def active_contacts(self): + """Return active contacts in sortorder (then contactid) order.""" + return sorted( + (c for c in self.contacts if c.isactive), + key=lambda c: (c.sortorder, c.contactid or 0)) + + def to_dict(self, with_contacts=True): + """Serialize the team, nesting its active contacts by default.""" + result = super().to_dict() + if with_contacts: + result['contacts'] = [c.to_dict() for c in self.active_contacts()] + return result + + def __repr__(self): + return f"" + + +class SupportTeamContact(BaseModel): + """A person to contact for a support team.""" + __tablename__ = 'supportteamcontacts' + + contactid = db.Column(db.Integer, primary_key=True) + supportteamid = db.Column( + db.Integer, + db.ForeignKey('supportteams.supportteamid', ondelete='CASCADE'), + nullable=False) + name = db.Column(db.String(100), nullable=False) + sso = db.Column(db.String(50)) + sortorder = db.Column(db.Integer, nullable=False, default=0) + + team = db.relationship('SupportTeam', back_populates='contacts') + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/user.py b/shopdb/core/models/user.py new file mode 100644 index 00000000..f672c16d --- /dev/null +++ b/shopdb/core/models/user.py @@ -0,0 +1,241 @@ +"""User and authentication models.""" + +from datetime import datetime, timezone +from shopdb.extensions import db +from .base import BaseModel + + +# Association table for user roles (many-to-many) +userroles = db.Table( + 'userroles', + db.Column('userid', db.Integer, db.ForeignKey('users.userid'), primary_key=True), + db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True) +) + +# Association table for role permissions (many-to-many) +rolepermissions = db.Table( + 'rolepermissions', + db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True), + db.Column('permissionid', db.Integer, db.ForeignKey('permissions.permissionid'), primary_key=True) +) + + +class Permission(db.Model): + """ + Permission model for granular access control. + + Permissions are predefined and assigned to roles. + """ + __tablename__ = 'permissions' + + permissionid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.String(255)) + category = db.Column(db.String(50), default='general') # For grouping in UI + + # Core permission catalog. This is CORE ONLY - permissions for a plugin + # domain (machines, computers, printers, network, knowledgebase, + # notifications, usb, warranty, measuringtools) live in that plugin's + # BasePlugin.get_permissions() hook, not here (plugin-is-the-product). The + # merged core+plugins view is full_permission_catalog() below. + CORE_PERMISSIONS = [ + # Assets + ('assets.view', 'View assets', 'assets'), + ('assets.create', 'Create assets', 'assets'), + ('assets.edit', 'Edit assets', 'assets'), + ('assets.delete', 'Delete assets', 'assets'), + # Applications + ('applications.view', 'View applications', 'applications'), + ('applications.create', 'Create applications', 'applications'), + ('applications.edit', 'Edit applications', 'applications'), + ('applications.delete', 'Delete applications', 'applications'), + # Reports + ('reports.view', 'View reports', 'reports'), + ('reports.export', 'Export reports', 'reports'), + # Settings + ('settings.view', 'View settings', 'admin'), + ('settings.edit', 'Edit settings', 'admin'), + # Users + ('users.view', 'View users', 'admin'), + ('users.create', 'Create users', 'admin'), + ('users.edit', 'Edit users', 'admin'), + ('users.delete', 'Delete users', 'admin'), + # Audit + ('audit.view', 'View audit logs', 'admin'), + # API tokens + ('apitokens.create', 'Create and manage API tokens', 'apitokens'), + # Collector service tokens. A token scoped to ONLY this permission is a + # collector service token: it authorizes the collector ingest API and + # nothing else (scoped tokens pass require_permission only for listed + # perms, and this perm gates no other route). See collector.py. + ('collector.ingest', 'Submit collector payloads (fleet reporting)', + 'collector'), + ] + + def __repr__(self): + return f"" + + @classmethod + def seed_entries(cls, entries): + """Idempotently create Permission rows from (name, desc, category) + tuples (or equivalent dicts). Returns how many were created.""" + created = 0 + for name, description, category in normalize_permission_entries(entries): + if not cls.query.filter_by(name=name).first(): + perm = cls(name=name, description=description, category=category) + db.session.add(perm) + created += 1 + return created + + @classmethod + def seed(cls): + """Seed the full permission catalog (core plus enabled plugins).""" + return cls.seed_entries(full_permission_catalog()) + + +def normalize_permission_entries(entries): + """Coerce catalog entries to (name, description, category) tuples. + + Accepts tuples/lists in that order, or dicts with those keys, so a plugin + may return either shape from get_permissions(). + """ + normalized = [] + for entry in entries or []: + if isinstance(entry, dict): + normalized.append(( + entry['name'], + entry.get('description', ''), + entry.get('category', 'general'), + )) + else: + name, description, category = entry + normalized.append((name, description, category)) + return normalized + + +def full_permission_catalog(include_disabled=False): + """The full permission catalog: core permissions plus every LOADED plugin's + get_permissions(). + + By default only ENABLED plugins contribute, so a disabled plugin's + permissions are not offered for new scope grants or role edits (existing + Permission rows and role links persist - see the get_permissions docstring). + Pass include_disabled=True to include all installed plugins. + + Follows the dashboard.py consumer pattern: a broken plugin is re-raised in + dev/test and isolated (logged) in prod. Returns (name, description, + category) tuples. + """ + from flask import current_app, has_app_context + + catalog = list(Permission.CORE_PERMISSIONS) + if not has_app_context(): + return catalog + pm = current_app.extensions.get('plugin_manager') + if not pm: + return catalog + + for name, plugin in pm.get_all_plugins().items(): + if not include_disabled and not pm.registry.is_enabled(name): + continue + try: + catalog.extend( + normalize_permission_entries(plugin.get_permissions())) + except Exception: + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_permissions failed', name) + return catalog + + +class Role(BaseModel): + """User role model.""" + __tablename__ = 'roles' + + roleid = db.Column(db.Integer, primary_key=True) + rolename = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + + # Permissions relationship + permissions = db.relationship( + 'Permission', + secondary=rolepermissions, + backref=db.backref('roles', lazy='dynamic') + ) + + def __repr__(self): + return f"" + + def haspermission(self, permission_name: str) -> bool: + """Check if role has a specific permission.""" + # Admin role has all permissions + if self.rolename == 'admin': + return True + return any(p.name == permission_name for p in self.permissions) + + def getpermissionnames(self) -> list: + """Get list of permission names.""" + if self.rolename == 'admin': + return [p[0] for p in full_permission_catalog()] + return [p.name for p in self.permissions] + + +class User(BaseModel): + """User model for authentication.""" + __tablename__ = 'users' + + userid = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(100), unique=True, nullable=False, index=True) + email = db.Column(db.String(255), unique=True, nullable=False) + passwordhash = db.Column(db.String(255), nullable=False) + + # Profile + firstname = db.Column(db.String(100)) + lastname = db.Column(db.String(100)) + + # Status + lastlogindate = db.Column(db.DateTime) + failedlogins = db.Column(db.Integer, default=0) + lockeduntil = db.Column(db.DateTime) + # Forced password change: set true when an admin creates the account, so the + # user is steered through a password change before landing in the app. + mustchangepassword = db.Column(db.Boolean, default=False, nullable=False) + + # Relationships + roles = db.relationship( + 'Role', + secondary=userroles, + backref=db.backref('users', lazy='dynamic') + ) + + def __repr__(self): + return f"" + + @property + def islocked(self): + """Check if account is locked.""" + if self.lockeduntil: + return datetime.now(timezone.utc).replace(tzinfo=None) < self.lockeduntil + return False + + def hasrole(self, rolename: str) -> bool: + """Check if user has a specific role.""" + return any(r.rolename == rolename for r in self.roles) + + def haspermission(self, permission_name: str) -> bool: + """Check if user has a specific permission through any role.""" + # Admin role has all permissions + if self.hasrole('admin'): + return True + return any(r.haspermission(permission_name) for r in self.roles) + + def getpermissions(self) -> list: + """Get list of all permission names from all roles.""" + if self.hasrole('admin'): + return [p[0] for p in full_permission_catalog()] + + perms = set() + for role in self.roles: + perms.update(role.getpermissionnames()) + return list(perms) diff --git a/shopdb/core/models/vendor.py b/shopdb/core/models/vendor.py new file mode 100644 index 00000000..46532888 --- /dev/null +++ b/shopdb/core/models/vendor.py @@ -0,0 +1,20 @@ +"""Vendor model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class Vendor(BaseModel): + """Vendor/Manufacturer model.""" + __tablename__ = 'vendors' + + vendorid = db.Column(db.Integer, primary_key=True) + vendor = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + website = db.Column(db.String(255)) + supportphone = db.Column(db.String(50)) + supportemail = db.Column(db.String(100)) + notes = db.Column(db.Text) + + def __repr__(self): + return f"" diff --git a/shopdb/core/schemas/__init__.py b/shopdb/core/schemas/__init__.py new file mode 100644 index 00000000..37609517 --- /dev/null +++ b/shopdb/core/schemas/__init__.py @@ -0,0 +1 @@ +"""Core Marshmallow schemas.""" diff --git a/shopdb/core/services/__init__.py b/shopdb/core/services/__init__.py new file mode 100644 index 00000000..0791aaa4 --- /dev/null +++ b/shopdb/core/services/__init__.py @@ -0,0 +1 @@ +"""Core services.""" diff --git a/shopdb/core/services/dualpath.py b/shopdb/core/services/dualpath.py new file mode 100644 index 00000000..d10a6dc6 --- /dev/null +++ b/shopdb/core/services/dualpath.py @@ -0,0 +1,115 @@ +"""Dualpath single-machine collapse resolution. + +A Dualpath relationship pair is ONE physical dual-bay machine (single +controller, one bay-selector switch) recorded as two asset rows. Most +facilities want lists, counts, and the map to show that pair as a single +machine; the data model always keeps both rows. This module resolves the +pairs so the consumers (machines list, dashboard/report counts, floor map) +can collapse them, and is exposed via the plugin contract surface +(shopdb.api) so the machines plugin can reach it contract-purely. + +PRIMARY = the pair member with the lower natural-sort assetnumber; the other +member is SECONDARY and is the one hidden when collapsing. Pairs are derived +from active assetrelationships rows whose type is named 'Dualpath' (either +direction; mirrored rows in both directions collapse to one pair). Only pairs +where BOTH assets are active are resolved. +""" + +import re +from collections import namedtuple + +from shopdb.core.models import Asset, AssetRelationship, RelationshipType + +# secondaryassetids: set of the non-primary bay asset ids (hide when collapsing) +# partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for EVERY pair member, +# primary and secondary alike, so detail pages can show a sibling +# banner from whichever bay you land on. +DualpathCollapse = namedtuple('DualpathCollapse', ['secondaryassetids', 'partnerbyasset']) + +DUALPATH_TYPE_NAME = 'Dualpath' + + +def _naturalkey(assetnumber): + # split into digit/non-digit chunks so 2007 sorts before 2008 and before 10a + parts = re.split(r'(\d+)', assetnumber or '') + return [int(p) if p.isdigit() else p.lower() for p in parts] + + +def resolve_dualpath_pairs(): + """Resolve active Dualpath pairs into a DualpathCollapse. + + Direction-blind and dedup-safe: rows stored in either direction (or both) + for the same two assets collapse to one pair. Ignores the site toggle; + callers gate on dualpath_single_machine_enabled() where the collapse should + only apply when the setting is on (the detail-page banner shows always). + """ + reltype = RelationshipType.query.filter_by( + relationshiptype=DUALPATH_TYPE_NAME).first() + if not reltype: + return DualpathCollapse(set(), {}) + + rows = AssetRelationship.query.filter( + AssetRelationship.relationshiptypeid == reltype.relationshiptypeid, + AssetRelationship.isactive == True, + ).all() + if not rows: + return DualpathCollapse(set(), {}) + + # batch-load the involved assets (assetnumber + active flag) in one query + involved = set() + for row in rows: + involved.add(row.sourceassetid) + involved.add(row.targetassetid) + assetbyid = { + a.assetid: a + for a in Asset.query.filter(Asset.assetid.in_(involved)).all() + } + + secondaryassetids = set() + partnerbyasset = {} + seenpairs = set() + for row in rows: + aid, bid = row.sourceassetid, row.targetassetid + if aid == bid: + continue # defensive: no self-pairs + pairkey = frozenset((aid, bid)) + if pairkey in seenpairs: + continue # mirrored row already handled + seenpairs.add(pairkey) + + aone = assetbyid.get(aid) + atwo = assetbyid.get(bid) + # collapse only affects visible/counted (active) assets + if not aone or not atwo or not aone.isactive or not atwo.isactive: + continue + + # PRIMARY = lower natural-sort assetnumber; SECONDARY is the other bay + if _naturalkey(aone.assetnumber) <= _naturalkey(atwo.assetnumber): + primary, secondary = aone, atwo + else: + primary, secondary = atwo, aone + + secondaryassetids.add(secondary.assetid) + partnerbyasset[primary.assetid] = { + 'assetid': secondary.assetid, + 'assetnumber': secondary.assetnumber, + } + partnerbyasset[secondary.assetid] = { + 'assetid': primary.assetid, + 'assetnumber': primary.assetnumber, + } + + return DualpathCollapse(secondaryassetids, partnerbyasset) + + +def dualpath_single_machine_enabled(): + """True when the site treats Dualpath pairs as one machine (default true). + + Reads the cached settings; the row is absent on an un-seeded site, in which + case the default (true - 'most places' consider a dual-bay pair one machine) + applies. + """ + # imported here to avoid a settings<->api import cycle at module load + from shopdb.core.api.settings import get_cached_settings + settings = get_cached_settings() + return bool(settings.get('dualpath_single_machine', True)) diff --git a/shopdb/exceptions.py b/shopdb/exceptions.py new file mode 100644 index 00000000..4c183b90 --- /dev/null +++ b/shopdb/exceptions.py @@ -0,0 +1,70 @@ +"""Custom exceptions for ShopDB.""" + + +class ShopDBException(Exception): + """Base exception for ShopDB.""" + + def __init__(self, message: str, code: str = None, details: dict = None): + super().__init__(message) + self.message = message + self.code = code or 'SHOPDB_ERROR' + self.details = details or {} + + +class ValidationError(ShopDBException): + """Validation error.""" + + def __init__(self, message: str, details: dict = None): + super().__init__(message, 'VALIDATION_ERROR', details) + + +class NotFoundError(ShopDBException): + """Resource not found error.""" + + def __init__(self, resource: str, identifier): + message = f"{resource} with ID {identifier} not found" + super().__init__(message, 'NOT_FOUND', {'resource': resource, 'id': identifier}) + + +class AuthenticationError(ShopDBException): + """Authentication error.""" + + def __init__(self, message: str = "Authentication required"): + super().__init__(message, 'UNAUTHORIZED') + + +class AuthorizationError(ShopDBException): + """Authorization error.""" + + def __init__(self, message: str = "Permission denied"): + super().__init__(message, 'FORBIDDEN') + + +class ConflictError(ShopDBException): + """Conflict error (e.g., duplicate entry).""" + + def __init__(self, message: str, details: dict = None): + super().__init__(message, 'CONFLICT', details) + + +class PluginError(ShopDBException): + """Plugin-related error.""" + + def __init__(self, message: str, plugin_name: str = None): + super().__init__(message, 'PLUGIN_ERROR', {'plugin': plugin_name}) + + +class PluginNotFoundError(PluginError): + """A plugin directory or manifest is missing.""" + + +class PluginContractError(PluginError): + """A plugin violates the BasePlugin contract (no subclass, missing hooks).""" + + +class PluginVersionError(PluginError): + """A plugin's core_version range does not include the framework's __contract_version__.""" + + +class PluginDependencyError(PluginError): + """A plugin declares a dependency on another plugin that is missing or not enabled.""" diff --git a/shopdb/extensions.py b/shopdb/extensions.py new file mode 100644 index 00000000..847a03b6 --- /dev/null +++ b/shopdb/extensions.py @@ -0,0 +1,28 @@ +"""Flask extensions initialization.""" + +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_jwt_extended import JWTManager +from flask_cors import CORS +from flask_marshmallow import Marshmallow +from flask_caching import Cache + +# Initialize extensions without app +db = SQLAlchemy() +migrate = Migrate() +jwt = JWTManager() +cors = CORS() +ma = Marshmallow() +cache = Cache() + + +def init_extensions(app): + """Initialize all Flask extensions with app.""" + db.init_app(app) + migrate.init_app(app, db) + jwt.init_app(app) + cors.init_app(app, resources={ + r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')} + }) + ma.init_app(app) + cache.init_app(app) diff --git a/shopdb/plugins/__init__.py b/shopdb/plugins/__init__.py new file mode 100644 index 00000000..1729ab2d --- /dev/null +++ b/shopdb/plugins/__init__.py @@ -0,0 +1,399 @@ +"""Plugin manager - main entry point for plugin system.""" + +from pathlib import Path +from typing import Dict, List, Optional +from flask import Flask +import logging + +from .base import BasePlugin, PluginMeta +from .registry import PluginRegistry, PluginState +from .loader import PluginLoader +from .migrations import PluginMigrationManager + +logger = logging.getLogger(__name__) + +__all__ = [ + 'PluginManager', + 'BasePlugin', + 'PluginMeta', + 'PluginRegistry', + 'PluginState', + 'plugin_manager' +] + + +class PluginManager: + """ + Central manager for all plugin operations. + + Usage: + plugin_manager = PluginManager() + plugin_manager.init_app(app, db) + + # In CLI: + plugin_manager.install_plugin('printers') + """ + + def __init__(self): + self.registry: Optional[PluginRegistry] = None + self.loader: Optional[PluginLoader] = None + self.migration_manager: Optional[PluginMigrationManager] = None + self._app: Optional[Flask] = None + self._db = None + # API prefixes already claimed by a registered plugin blueprint, to + # detect two plugins overlapping on the same /api/... namespace. + self._registered_prefixes: set = set() + + def init_app(self, app: Flask, db) -> None: + """Initialize plugin manager with Flask app.""" + self._app = app + self._db = db + # Reset per-app so the prefix-uniqueness guard tracks only this app's + # registrations (the manager is a process-wide singleton; tests build + # multiple apps from it). + self._registered_prefixes = set() + + # Setup paths + instance_path = Path(app.instance_path) + plugins_dir = Path(app.root_path).parent / 'plugins' + + # Initialize components + self.registry = PluginRegistry(instance_path / 'plugins.json') + self.loader = PluginLoader(plugins_dir, self.registry) + self.migration_manager = PluginMigrationManager( + plugins_dir, + app.config.get('SQLALCHEMY_DATABASE_URI') + ) + + # Load enabled plugins + self._load_enabled_plugins() + + # Store on app for access + app.extensions['plugin_manager'] = self + + def _load_enabled_plugins(self) -> None: + """Load and register all enabled plugins.""" + plugins = self.loader.load_enabled_plugins(self._app, self._db) + + for name, plugin in plugins.items(): + self._register_plugin_components(plugin) + + def upgrade_all_plugins(self) -> Dict[str, str]: + """Run pending Alembic migrations for every discovered plugin. + + Returns {plugin_name: 'ok'|'no-migrations'|}. Skips + plugins with no migrations/ directory. Driven by the CLI + (`flask plugin upgrade-all`), which every deploy runs after + `flask db upgrade`. Each bundled plugin's chain begins with a + stamp-only anchor (the core chain already built its tables); later + per-plugin migrations extend that chain. See ADR-008. Idempotent. + """ + results: Dict[str, str] = {} + if not self.migration_manager: + return results + plugin_names = list(self.registry.list_installed().keys()) \ + if hasattr(self.registry, 'list_installed') else [] + if not plugin_names: + # Fall back to whatever the loader found on disk. + plugin_names = self.loader.discover_plugins() + for name in plugin_names: + if not self.migration_manager.has_pending_migrations(name): + results[name] = 'no-migrations' + continue + try: + ok = self.migration_manager.run_plugin_migrations(name) + results[name] = 'ok' if ok else 'failed' + except Exception as ex: + results[name] = f'error: {ex}' + return results + + def _register_plugin_components(self, plugin: BasePlugin) -> None: + """Register plugin's blueprint, models, CLI commands, etc.""" + # Register blueprint + blueprint = plugin.get_blueprint() + if blueprint: + prefix = plugin.meta.api_prefix + # Guard against two plugins claiming the same API prefix; Flask only + # rejects duplicate blueprint names, not overlapping url_prefixes, so + # an overlap would silently shadow routes. + if prefix in self._registered_prefixes: + raise ValueError( + f"Plugin {plugin.meta.name} api_prefix '{prefix}' is already " + f"claimed by another blueprint" + ) + self._app.register_blueprint(blueprint, url_prefix=prefix) + self._registered_prefixes.add(prefix) + logger.debug(f"Registered blueprint: {prefix}") + + # Register CLI commands + for cmd in plugin.get_cli_commands(): + self._app.cli.add_command(cmd) + + def discover_available(self) -> List[Dict]: + """ + Get list of all available plugins (installed or not). + Returns list of plugin info dicts. + """ + available = [] + + for name in self.loader.discover_plugins(): + plugin_class = self.loader.load_plugin_class(name) + if plugin_class: + try: + temp = plugin_class() + meta = temp.meta + state = self.registry.get(name) + + try: + config_schema = temp.get_config_schema() + except Exception: + config_schema = [] + try: + provisioning_note = temp.get_provisioning_note() + except Exception: + provisioning_note = None + manifest = self.loader.load_manifest(name) + available.append({ + 'name': meta.name, + # Human label for UIs. Manifest display_name wins; else + # title-case the machine name (right for most, e.g. + # "computers" -> "Computers"). + 'displayname': (manifest.get('display_name') + or meta.name.replace('_', ' ').title()), + 'version': meta.version, + 'description': meta.description, + 'author': meta.author, + 'dependencies': meta.dependencies, + 'installed': state is not None, + 'enabled': state.enabled if state else False, + 'installedat': state.installed_at if state else None, + 'config_schema': config_schema, + 'provisioning_note': provisioning_note, + 'default_enabled': manifest.get('default_enabled', True), + }) + except Exception as e: + logger.warning(f"Error inspecting plugin {name}: {e}") + + return available + + def install_plugin(self, name: str, run_migrations: bool = True) -> bool: + """ + Install a plugin. + + Steps: + 1. Verify plugin exists + 2. Check dependencies + 3. Run database migrations + 4. Register in registry + 5. Call plugin's on_install hook + """ + # Check if already installed + if self.registry.is_installed(name): + logger.warning(f"Plugin {name} is already installed") + return False + + # Read metadata from the manifest (single source of truth) instead of + # instantiating the plugin class just to inspect deps/version. + manifest = self.loader.load_manifest(name) + if not manifest: + logger.error(f"Plugin {name} not found") + return False + manifest_version = manifest.get('version') + + # Check dependencies + for dep in manifest.get('dependencies', []): + if not self.registry.is_installed(dep): + logger.error( + f"Plugin {name} requires {dep} to be installed first" + ) + return False + + # Run migrations + if run_migrations: + success = self.migration_manager.run_plugin_migrations(name) + if not success: + logger.error(f"Failed to run migrations for {name}") + return False + + # Register plugin + # Plugins that provision extra tables install disabled until a site + # opts in (manifest default_enabled=false). + self.registry.register(name, manifest_version, + enabled=manifest.get('default_enabled', True)) + + # Load the plugin + plugin = self.loader.load_plugin(name, self._app, self._db) + if plugin: + self._register_plugin_components(plugin) + self._seed_plugin_permissions(plugin) + plugin.on_install(self._app) + + logger.info(f"Installed plugin: {name} v{manifest_version}") + return True + + def _seed_plugin_permissions(self, plugin: BasePlugin) -> None: + """Idempotently create Permission rows for a plugin's declared perms. + + Runs at install and enable so a fresh plugin's RBAC permissions exist + without a separate `flask seed permissions` pass. Best-effort: a plugin + that raises must not abort the lifecycle.""" + try: + entries = plugin.get_permissions() or [] + except Exception: + logger.exception( + "get_permissions failed for %s", plugin.meta.name) + return + if not entries: + return + from shopdb.core.models import Permission + with self._app.app_context(): + created = Permission.seed_entries(entries) + if created: + self._db.session.commit() + logger.info( + "Seeded %d permission(s) for plugin %s", + created, plugin.meta.name) + + def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool: + """ + Uninstall a plugin. + + Args: + name: Plugin name + remove_data: If True, run downgrade migrations to remove tables + """ + if not self.registry.is_installed(name): + logger.warning(f"Plugin {name} is not installed") + return False + + # Check if other plugins depend on this one + for other_name in self.registry.get_enabled_plugins(): + if other_name == name: + continue + other_plugin = self.loader.get_loaded_plugin(other_name) + if other_plugin and name in other_plugin.meta.dependencies: + logger.error( + f"Cannot uninstall {name}: {other_name} depends on it" + ) + return False + + # Get plugin instance + plugin = self.loader.get_loaded_plugin(name) + + # Call on_uninstall hook + if plugin: + plugin.on_uninstall(self._app) + + # Optionally remove data + if remove_data: + self.migration_manager.downgrade_plugin(name) + + # Unregister + self.registry.unregister(name) + + logger.info(f"Uninstalled plugin: {name}") + return True + + def enable_plugin(self, name: str) -> bool: + """Enable a disabled plugin.""" + if not self.registry.is_installed(name): + logger.error(f"Plugin {name} is not installed") + return False + + if self.registry.is_enabled(name): + logger.info(f"Plugin {name} is already enabled") + return True + + # Check dependencies are enabled. Read deps from the manifest, not by + # instantiating the plugin class (manifest is the single source of + # truth; instantiating fires __init__ side effects unnecessarily). + manifest = self.loader.load_manifest(name) + for dep in manifest.get('dependencies', []): + if not self.registry.is_enabled(dep): + logger.error(f"Cannot enable {name}: {dep} is not enabled") + return False + + self.registry.enable(name) + + # Surface (do not auto-apply) a plugin chain that is ahead of the DB, so + # an operator enabling a plugin knows to run `flask plugin upgrade-all`. + try: + if self.migration_manager and \ + self.migration_manager.has_unapplied_migrations(name): + logger.warning( + f"Plugin {name} has unapplied migrations; " + f"run 'flask plugin upgrade-all'" + ) + except Exception: + logger.debug(f"Could not check migration state for {name}") + + # Fire the on_enable hook best-effort. Do NOT register the blueprint + # here: Flask forbids register_blueprint after the first request, so + # routes/nav for a re-enabled plugin take effect on the next restart + # (symmetric with disable). + try: + plugin = self.loader.load_plugin(name, self._app, self._db) + if plugin: + self._seed_plugin_permissions(plugin) + plugin.on_enable(self._app) + except Exception: + logger.exception(f"on_enable hook failed for plugin {name}") + + logger.info(f"Enabled plugin: {name}") + return True + + def disable_plugin(self, name: str) -> bool: + """Disable an enabled plugin.""" + if not self.registry.is_enabled(name): + logger.info(f"Plugin {name} is already disabled") + return True + + # Check if other plugins depend on this one + for other_name in self.registry.get_enabled_plugins(): + if other_name == name: + continue + other_plugin = self.loader.get_loaded_plugin(other_name) + if other_plugin and name in other_plugin.meta.dependencies: + logger.error( + f"Cannot disable {name}: {other_name} depends on it" + ) + return False + + plugin = self.loader.get_loaded_plugin(name) + if plugin: + plugin.on_disable(self._app) + + self.registry.disable(name) + logger.info(f"Disabled plugin: {name}") + return True + + def get_plugin(self, name: str) -> Optional[BasePlugin]: + """Get a loaded plugin instance.""" + return self.loader.get_loaded_plugin(name) + + def get_all_plugins(self) -> Dict[str, BasePlugin]: + """Get all loaded plugins.""" + return self.loader.get_all_loaded() + + def get_service(self, name: str): + """Resolve a service exposed by an enabled plugin via get_services(). + + Consumer for the BasePlugin.get_services hook: searches enabled plugins + for one that registers `name` and returns the registered value (a service + class or factory). Returns None if no enabled plugin provides it. This is + how one plugin obtains another's service (e.g. the Zabbix service). + """ + for plugin_name, plugin in self.get_all_plugins().items(): + if not self.registry.is_enabled(plugin_name): + continue + try: + services = plugin.get_services() or {} + except Exception: + continue + if name in services: + return services[name] + return None + + +# Global plugin manager instance +plugin_manager = PluginManager() diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py new file mode 100644 index 00000000..ff7dd64f --- /dev/null +++ b/shopdb/plugins/alembic_template.py @@ -0,0 +1,166 @@ +"""Shared Alembic env.py logic for bundled plugins. + +Every bundled plugin that owns tables (computers, employees, knowledgebase, +machines, network, notifications, printers, slides, usb, warranty) has a +`migrations/env.py` that does the minimum: + + import os + os.environ['PLUGIN_NAME'] = 'computers' + from shopdb.plugins.alembic_template import run_migrations + run_migrations() + +This module wires the plugin's models into a MetaData object filtered to +only the tables that belong to that plugin, then runs Alembic in either +offline or online mode against the Flask app's configured engine. + +Ownership cutover (see ADR-008): the core Alembic chain created every table +that exists through its head (`7d16_directoryemployees`), including the plugin +tables. Each plugin's `0001` migration is therefore a stamp-only no-op that +just records the anchor revision in `alembic_version_`. NEW plugin +schema changes land as `plugins//migrations/000N` from here on, never in +the core chain. + +Plugin tables must be importable via `plugins..models`. Plugins +register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the +filter is explicit (avoids depending on import-side-effect global state). +""" +from __future__ import annotations + +import importlib +import logging +import os +from typing import Iterable + +from alembic import context +from sqlalchemy import MetaData, pool + +logger = logging.getLogger('alembic.env.plugin') + +# Explicit table-ownership map. Adding tables to a plugin requires updating +# this dict so the per-plugin migration knows which tables to include. +PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { + 'computers': ('computertypes', 'computers', 'computerinstalledapps', + 'accessprotocols', 'computeraccess'), + 'employees': ('directoryemployees',), + 'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes', + 'manifestentryhostnames', 'manifestentrymachinenumbers', + 'manifestinusechecks', 'manifestinusecheckprocesses', + 'manifestpublishedversions', 'manifestpayloads', + 'manifestenforcementreports', 'manifestenforcementresults', + 'pctypealiases'), + 'knowledgebase': ('knowledgebase',), + 'machines': ('machinetypes', 'machines'), + 'measuringtools': ('measuringtooltypes', 'measuringtools'), + 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), + 'notifications': ('notificationtypes', 'notifications'), + 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'), + 'slides': ('tvslides',), + 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), + 'warranty': ('warranties', 'warrantyassets'), +} + + +def _get_plugin_metadata(plugin_name: str) -> MetaData: + """Import the plugin's models and return a MetaData containing only its + declared tables (filtered via PLUGIN_TABLE_OWNERS).""" + owned = set(PLUGIN_TABLE_OWNERS.get(plugin_name, ())) + if not owned: + raise RuntimeError( + f"PLUGIN_TABLE_OWNERS has no entry for plugin '{plugin_name}'. " + f"Update shopdb/plugins/alembic_template.py." + ) + + # Importing models attaches them to the global db.metadata. + importlib.import_module(f'plugins.{plugin_name}.models') + from shopdb.extensions import db + full = db.metadata + + plugin_md = MetaData() + for table in list(full.tables.values()): + if table.name in owned: + table.to_metadata(plugin_md) + return plugin_md + + +def create_plugin_tables(plugin_name: str): + """Emit CreateTable DDL for every table this plugin owns. Idempotent + via Alembic's batch_op.create_table behavior (raises if exists; the + baseline migration is meant to run against an empty schema). + + Called from each plugin's 0001_baseline.py upgrade() so the table + definitions stay sourced from the SQLAlchemy models rather than being + duplicated in handwritten Alembic ops. + """ + from alembic import op + from sqlalchemy.schema import CreateTable + + md = _get_plugin_metadata(plugin_name) + bind = op.get_bind() + # Sort by FK dependency so parent tables are created first. + for table in md.sorted_tables: + op.execute(str(CreateTable(table).compile(dialect=bind.dialect))) + + +def drop_plugin_tables(plugin_name: str): + """Mirror of create_plugin_tables for downgrade(). Drops in reverse FK + order.""" + from alembic import op + + md = _get_plugin_metadata(plugin_name) + for table in reversed(md.sorted_tables): + op.execute(f'DROP TABLE IF EXISTS "{table.name}"') + + +def run_migrations(): + """Entry point called by each plugin's migrations/env.py.""" + plugin_name = os.environ.get('PLUGIN_NAME') + if not plugin_name: + raise RuntimeError("PLUGIN_NAME env var must be set before run_migrations()") + + config = context.config + target_metadata = _get_plugin_metadata(plugin_name) + + # Per-plugin version table so each plugin's chain is independent of core + # Alembic's alembic_version table. + version_table = f'alembic_version_{plugin_name}' + + db_url = config.get_main_option('sqlalchemy.url') + if not db_url: + # Pull from the Flask app config if running inside an app context + # (e.g. via flask plugin migrate ). + try: + from flask import current_app + db_url = current_app.config['SQLALCHEMY_DATABASE_URI'] + config.set_main_option('sqlalchemy.url', db_url.replace('%', '%%')) + except Exception as ex: + raise RuntimeError( + "sqlalchemy.url not set and no Flask app context available. " + f"Original error: {ex}" + ) + + if context.is_offline_mode(): + context.configure( + url=db_url, + target_metadata=target_metadata, + literal_binds=True, + version_table=version_table, + include_schemas=False, + ) + with context.begin_transaction(): + context.run_migrations() + else: + # Build the engine straight from the resolved URL. The plugin manager + # drives this via a programmatic alembic Config (no ini file), so + # config.get_section returns an empty dict and engine_from_config would + # find no sqlalchemy.url. db_url is already resolved above. + from sqlalchemy import create_engine + connectable = create_engine(db_url, poolclass=pool.NullPool) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + version_table=version_table, + include_schemas=False, + ) + with context.begin_transaction(): + context.run_migrations() diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py new file mode 100644 index 00000000..6a39e2a2 --- /dev/null +++ b/shopdb/plugins/base.py @@ -0,0 +1,350 @@ +"""Base plugin class that all plugins must inherit from.""" + +from abc import ABC, abstractmethod +from typing import List, Dict, Optional, Type +from dataclasses import dataclass, field +from flask import Flask, Blueprint + + +@dataclass +class PluginMeta: + """Plugin metadata container.""" + + name: str + version: str + description: str + author: str = "" + dependencies: List[str] = field(default_factory=list) + # Default to the current pre-1.0 contract range; tighten when 1.0 lands + core_version: str = ">=0.2.0,<1.0.0" + api_prefix: str = None + + def __post_init__(self): + if self.api_prefix is None: + self.api_prefix = f"/api/{self.name.replace('_', '-')}" + + +class BasePlugin(ABC): + """ + Base class for all ShopDB plugins. + + Plugins must implement: + - meta: PluginMeta instance + - get_blueprint(): Return Flask Blueprint for API routes + - get_models(): Return list of SQLAlchemy model classes + + Optionally implement: + - init_app(app, db): Custom initialization + - get_cli_commands(): Return Click commands + - get_services(): Return service classes + - on_install(): Called when plugin is installed + - on_uninstall(): Called when plugin is uninstalled + - on_enable(): Called when plugin is enabled + - on_disable(): Called when plugin is disabled + """ + + @property + @abstractmethod + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + pass + + @abstractmethod + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + pass + + @abstractmethod + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + pass + + def init_app(self, app: Flask, db) -> None: + """ + Initialize plugin with Flask app. + Override for custom initialization. + """ + pass + + def get_cli_commands(self) -> List: + """Return list of Click command groups/commands.""" + return [] + + def get_services(self) -> Dict[str, Type]: + """Return dict of service name -> service class.""" + return {} + + def get_provisioning_note(self) -> Optional[Dict]: + """Transparency note shown when a site enables this plugin. + + Return None for plugins that need no special setup. For plugins that + create extra tables (e.g. a self-hosted directory or USB tables), return: + { + 'tables': ['directoryemployees', ...], # created in the shopdb DB + 'note': 'Plain-language what/why.', + 'docs': 'plugins//README.md', # where the schema lives + } + The setup wizard shows this the moment the plugin is checked. + """ + return None + + def get_config_schema(self) -> List[Dict]: + """Declare the config fields this plugin needs, for the setup wizard. + + Each field is a dict: + key - the Setting key (non-secret) it maps to + label - human label + type - 'text' | 'number' | 'password' + secret - True for credentials; these are NOT stored in the DB, the + wizard emits an .env line for the operator to paste instead + envvar - (secret only) the .env variable name to emit + default - optional default shown as a placeholder + help - optional hint + Return [] (default) if the plugin needs no configuration. + """ + return [] + + def get_setting(self, key: str, default=None): + """Read a plugin-scoped setting from the core Setting store. + + Settings are namespaced by plugin name to avoid collisions + across plugins. The on-disk key is `plugin..`. + + Returns the typed value (string, integer, boolean, etc.) or + the default if not set. + """ + from shopdb.core.models import Setting + namespaced_key = f'plugin.{self.meta.name}.{key}' + return Setting.get(namespaced_key, default) + + def set_setting(self, key: str, value, valuetype: str = 'string', + description: str = None) -> None: + """Write a plugin-scoped setting to the core Setting store. + + Settings are namespaced by plugin name. Persists immediately and + survives restarts. + """ + from shopdb.core.models import Setting + namespaced_key = f'plugin.{self.meta.name}.{key}' + Setting.set( + namespaced_key, + value, + valuetype=valuetype, + category=f'plugin.{self.meta.name}', + description=description, + ) + + def get_collector_schema(self) -> Optional[Dict]: + """Return JSON Schema describing the collector payload for this plugin. + + Return None if the plugin does not accept collector input. + + See ADR-006 for the contract. The schema must include: + - 'identityfield': name of the field that uniquely identifies an + asset across submissions (e.g., 'hostname' for PCs, + 'macaddress' for network devices). Used for idempotent upsert. + - 'fields': JSON Schema definitions for the rest of the payload. + + Plugins returning a non-None schema have an endpoint at + /api/collector/ auto-registered by the loader. + """ + return None + + def apply_collector_payload(self, payload: Dict) -> Dict: + """Idempotently upsert an asset from a validated collector payload. + + Called by the generic /api/collector/ endpoint after the + payload passed schema validation. Plugins that return a schema from + get_collector_schema must implement this. Return a dict with at least: + - 'action': 'created' | 'updated' | 'noop' + - 'assetid': the affected asset id (or None) + - 'warnings': list[str] + """ + raise NotImplementedError( + f"{self.meta.name} declares a collector schema but does not " + f"implement apply_collector_payload" + ) + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed via CLI.""" + pass + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled via CLI.""" + pass + + def on_enable(self, app: Flask) -> None: + """Called when plugin is enabled.""" + pass + + def on_disable(self, app: Flask) -> None: + """Called when plugin is disabled.""" + pass + + def get_dashboard_widgets(self) -> List[Dict]: + """ + Return dashboard widget definitions. + + Each widget: { + 'name': str, + 'component': str, # Frontend component name + 'endpoint': str, # API endpoint for data + 'size': str, # 'small', 'medium', 'large' + 'position': int # Order on dashboard + } + """ + return [] + + def get_navigation_items(self) -> List[Dict]: + """ + Return navigation menu items. + + Each item: { + 'name': str, + 'icon': str, + 'route': str, + 'position': int, + 'children': [] + } + """ + return [] + + def get_permissions(self) -> List: + """ + Return the RBAC permissions this plugin owns. + + Each entry is a (name, description, category) tuple, matching the core + permission catalog shape (dicts with those keys are also accepted): + + [ + ('machines.view', 'View machines', 'machines'), + ('machines.create', 'Create machines', 'machines'), + ('machines.edit', 'Edit machines', 'machines'), + ('machines.delete', 'Delete machines', 'machines'), + ] + + A plugin owns the permission names its own routes enforce via + require_permission; core no longer accumulates them. The names must + follow the naming convention (lowercase dotted, e.g. `machines.edit`). + + Consumed by full_permission_catalog(): core permissions plus every + ENABLED plugin's get_permissions(). That catalog backs `flask seed + permissions`, the role-management grid (GET /api/users/permissions), + and API-token scope validation. Plugin install/enable also seeds the + plugin's own permissions idempotently. + + Disabled plugins are skipped by the catalog, so their permissions are + no longer offered for new scope grants or new role assignments. The + Permission ROWS already in the database are NOT deleted, so roles that + already reference them keep working until an admin edits the role. A + broken plugin is isolated in prod and re-raised in dev/test. + + Return [] (the default) if the plugin needs no permissions. + """ + return [] + + def get_reports(self) -> List[Dict]: + """ + Return report card definitions for the Reports hub. + + Each entry: { + 'id': str, # stable report id + 'name': str, # card title + 'description': str, # one-line blurb + 'category': str, # grouping key (lowercase) + # plus EXACTLY ONE of: + 'route': str, # frontend path for a dedicated report page + 'endpoint': str, # API endpoint for generic inline rendering + } + + Consumed by GET /api/reports, which merges these after the static core + reports. Disabled plugins are skipped by the consumer. + """ + return [] + + def get_settings_cards(self) -> List[Dict]: + """ + Return settings-catalog card definitions (ADR-010). + + Each card contributes an entry to the settings rail + landing overview + without a plugin hand-editing the core settingsNav.js catalog. + + Each card: { + 'group': str, # rail group title (created if new) + 'to': str, # settings route the card links to + 'icon': str, # string key, mapped to a Lucide icon core-side + 'title': str, # card title + 'description': str, # one-line blurb + 'position': int, # order within the group + } + + Consumed by GET /api/pluginui/settings-cards, which merges enabled + plugins' cards into the core catalog. Disabled plugins are skipped; + a broken plugin is isolated in prod, re-raised in dev/test. + """ + return [] + + def get_asset_panels(self) -> List[Dict]: + """ + Return asset-detail extension-panel definitions (ADR-010). + + A generic core AssetPanel component renders each panel on the asset + detail pages whose type matches, fetching the panel's endpoint. This + replaces hand-composing a plugin panel component into each detail view. + + Each panel: { + 'id': str, # stable panel id + 'title': str, # panel heading + 'assettypes': List[str], # AssetType keys it appears on; ['*'] = all + 'endpoint': str, # data endpoint (may contain {assetid}) + 'render': str, # 'keyvalue' | 'table' | 'badge' + 'position': int, # order among panels + } + + Consumed by GET /api/pluginui/asset-panels?assetid=, which returns + the panels matching that asset's type. Disabled plugins + are skipped; a broken plugin is isolated in prod, re-raised in dev/test. + A panel needing bespoke UI is out of scope for the data-only hook. + """ + return [] + + def get_map_overlays(self) -> List[Dict]: + """ + Return shop-floor map overlay/decoration definitions (ADR-010). + + The map is data-driven off asset types + positions; an overlay adds + decoration data (a badge or ring on already-placed markers) plus a + legend entry, without the plugin shipping any map code. + + Each overlay: { + 'id': str, # stable overlay id + 'label': str, # legend label + 'endpoint': str, # returns [{assetid, color, label}] to decorate + 'style': str, # 'badge' | 'ring' + 'legend': bool, # True to add a legend entry + } + + Consumed by GET /api/pluginui/map-overlays. Disabled plugins are + skipped; a broken plugin is isolated in prod, re-raised in dev/test. + """ + return [] + + def get_asset_presentation(self) -> List[Dict]: + """ + Return asset-type presentation/routing definitions (ADR-010). + + Declares how a plugin-owned asset type renders in global-search rows + and cross-links: which icon to show and where the detail link points, + so core never hardcodes a plugin's route or icon. + + Each entry: { + 'assettype': str, # AssetType.assettype key the plugin owns + 'icon': str, # string key, mapped to a Lucide icon core-side + 'label': str, # human label for the type + 'route': str, # detail-route pattern (may contain {assetid}) + } + + Consumed by GET /api/pluginui/asset-presentation. Disabled plugins are + skipped; a broken plugin is isolated in prod, re-raised in dev/test. + """ + return [] diff --git a/shopdb/plugins/cli.py b/shopdb/plugins/cli.py new file mode 100644 index 00000000..66459673 --- /dev/null +++ b/shopdb/plugins/cli.py @@ -0,0 +1,281 @@ +"""Flask CLI commands for plugin management.""" + +import click +from flask import current_app +from flask.cli import with_appcontext + + +@click.group('plugin') +def plugin_cli(): + """Plugin management commands.""" + pass + + +@plugin_cli.command('list') +@with_appcontext +def list_plugins(): + """List all available plugins.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + return + + plugins = pm.discover_available() + + if not plugins: + click.echo("No plugins found in plugins directory.") + return + + # Format output + click.echo("") + click.echo(click.style("Available Plugins:", fg='cyan', bold=True)) + click.echo("-" * 60) + + for p in plugins: + if p['enabled']: + status = click.style("[Enabled]", fg='green') + elif p['installed']: + status = click.style("[Disabled]", fg='yellow') + else: + status = click.style("[Available]", fg='white') + + click.echo(f" {p['name']:20} v{p['version']:10} {status}") + if p['description']: + click.echo(f" {p['description'][:55]}...") + if p['dependencies']: + deps = ', '.join(p['dependencies']) + click.echo(f" Dependencies: {deps}") + + click.echo("") + + +@plugin_cli.command('install') +@click.argument('name') +@click.option('--skip-migrations', is_flag=True, help='Skip database migrations') +@with_appcontext +def install_plugin(name: str, skip_migrations: bool): + """ + Install a plugin. + + Usage: flask plugin install printers + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + click.echo(f"Installing plugin: {name}") + + if pm.install_plugin(name, run_migrations=not skip_migrations): + click.echo(click.style(f"Successfully installed {name}", fg='green')) + else: + click.echo(click.style(f"Failed to install {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('uninstall') +@click.argument('name') +@click.option('--remove-data', is_flag=True, help='Remove plugin database tables') +@click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?') +@with_appcontext +def uninstall_plugin(name: str, remove_data: bool): + """ + Uninstall a plugin. + + Usage: flask plugin uninstall printers + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + click.echo(f"Uninstalling plugin: {name}") + + if pm.uninstall_plugin(name, remove_data=remove_data): + click.echo(click.style(f"Successfully uninstalled {name}", fg='green')) + else: + click.echo(click.style(f"Failed to uninstall {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('enable') +@click.argument('name') +@with_appcontext +def enable_plugin(name: str): + """Enable a disabled plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + if pm.enable_plugin(name): + click.echo(click.style(f"Enabled {name}", fg='green')) + # Nudge the operator if the plugin's chain is ahead of the DB. + try: + if pm.migration_manager and \ + pm.migration_manager.has_unapplied_migrations(name): + click.echo(click.style( + f" {name} has unapplied migrations - " + f"run 'flask plugin upgrade-all'", fg='yellow')) + except Exception: + pass + else: + click.echo(click.style(f"Failed to enable {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('disable') +@click.argument('name') +@with_appcontext +def disable_plugin(name: str): + """Disable an enabled plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + if pm.disable_plugin(name): + click.echo(click.style(f"Disabled {name}", fg='green')) + else: + click.echo(click.style(f"Failed to disable {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('info') +@click.argument('name') +@with_appcontext +def plugin_info(name: str): + """Show detailed information about a plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + plugin_class = pm.loader.load_plugin_class(name) + if not plugin_class: + click.echo(click.style(f"Plugin {name} not found", fg='red')) + raise SystemExit(1) + + try: + temp = plugin_class() + meta = temp.meta + except Exception as e: + click.echo(click.style(f"Error loading plugin: {e}", fg='red')) + raise SystemExit(1) + + state = pm.registry.get(name) + + click.echo("") + click.echo("=" * 50) + click.echo(click.style(f"Plugin: {meta.name}", fg='cyan', bold=True)) + click.echo("=" * 50) + click.echo(f"Version: {meta.version}") + click.echo(f"Description: {meta.description}") + click.echo(f"Author: {meta.author or 'Unknown'}") + click.echo(f"API Prefix: {meta.api_prefix}") + click.echo(f"Dependencies: {', '.join(meta.dependencies) or 'None'}") + click.echo(f"Core Version: {meta.core_version}") + click.echo("") + + if state: + status = click.style('Enabled', fg='green') if state.enabled else click.style('Disabled', fg='yellow') + click.echo(f"Status: {status}") + click.echo(f"Installed: {state.installed_at}") + click.echo(f"Migrations: {len(state.migrations_applied)} applied") + else: + click.echo(f"Status: {click.style('Not installed', fg='white')}") + + click.echo("") + + +@plugin_cli.command('new') +@click.argument('name') +@click.option('--description', default='', help='One-sentence plugin description') +@click.option('--overwrite', is_flag=True, help='Overwrite existing plugin directory') +@with_appcontext +def new_plugin(name: str, description: str, overwrite: bool): + """Scaffold a new plugin from the bundled templates. + + Usage: flask plugin new cameras --description "Tracks shop-floor cameras" + """ + from pathlib import Path + from .scaffolder import scaffold_plugin, ScaffoldError + + plugins_dir = Path(current_app.root_path).parent / 'plugins' + + if not description: + description = f'{name.capitalize()} plugin (TODO: replace this description)' + + try: + target = scaffold_plugin( + name=name, + description=description, + plugins_dir=plugins_dir, + overwrite=overwrite, + ) + except ScaffoldError as e: + click.echo(click.style(f'Scaffold failed: {e}', fg='red')) + raise SystemExit(1) + + click.echo(click.style(f'Created plugin at {target}', fg='green')) + click.echo('') + click.echo('Next steps:') + click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields') + click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints') + click.echo(f' 3. Add plugins/{name}/migrations/ with an Alembic chain that') + click.echo(f' creates your tables (per-plugin chain, NOT the core chain;') + click.echo(f' see ADR-008). Register the tables in PLUGIN_TABLE_OWNERS.') + click.echo(f' 4. Run: flask plugin install {name}') + click.echo(f' 5. Run: flask plugin upgrade-all') + click.echo(f' 6. Run: pytest plugins/{name}/tests/') + + +@plugin_cli.command('migrate') +@click.argument('name') +@click.option('--revision', default='head', help='Target revision') +@with_appcontext +def migrate_plugin(name: str, revision: str): + """Run migrations for a specific plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + if not pm.registry.is_installed(name): + click.echo(click.style(f"Plugin {name} is not installed", fg='red')) + raise SystemExit(1) + + click.echo(f"Running migrations for {name}...") + + if pm.migration_manager.run_plugin_migrations(name, revision): + click.echo(click.style("Migrations completed", fg='green')) + else: + click.echo(click.style("Migration failed", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('upgrade-all') +@with_appcontext +def upgrade_all_plugins(): + """Run pending migrations for every discovered plugin. + + Idempotent. Run this after `flask db upgrade` on every deploy and + upgrade. It stamps each bundled plugin's anchor revision into + alembic_version_ and applies any per-plugin migrations added + after the ownership cutover (ADR-008). Safe to re-run at head. + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + results = pm.upgrade_all_plugins() + if not results: + click.echo("No plugins discovered.") + return + for name, status in sorted(results.items()): + if status == 'ok': + click.echo(click.style(f" {name:20} ok", fg='green')) + elif status == 'no-migrations': + click.echo(click.style(f" {name:20} no migrations", fg='yellow')) + else: + click.echo(click.style(f" {name:20} {status}", fg='red')) diff --git a/shopdb/plugins/loader.py b/shopdb/plugins/loader.py new file mode 100644 index 00000000..f59d079e --- /dev/null +++ b/shopdb/plugins/loader.py @@ -0,0 +1,276 @@ +"""Plugin discovery and loading. + +The loader reads each plugin's manifest.json before instantiating the +plugin class. Dependency sorting and contract-version compatibility +checks operate on manifests, not plugin instances. The plugin class is +imported and instantiated only after the manifest passes validation. + +Failure policy (per the enforcing-plugin-contract skill): +- In dev/test (app.config.DEBUG or TESTING true): re-raise the original + exception so failures are loud. +- In production: log the failure with full context and exclude the + plugin from registration. The framework keeps booting. +""" + +import importlib +import importlib.util +import json +import logging +from pathlib import Path +from typing import Dict, List, Type, Optional + +from flask import Flask +from packaging.specifiers import SpecifierSet, InvalidSpecifier +from packaging.version import Version, InvalidVersion + +from .base import BasePlugin +from .registry import PluginRegistry +from ..exceptions import ( + PluginError, + PluginNotFoundError, + PluginContractError, + PluginVersionError, + PluginDependencyError, +) + +logger = logging.getLogger(__name__) + + +class PluginLoader: + """Discovers and loads plugins from the plugins directory.""" + + def __init__(self, plugins_dir: Path, registry: PluginRegistry): + self.plugins_dir = plugins_dir + self.registry = registry + self._loaded_plugins: Dict[str, BasePlugin] = {} + self._plugin_classes: Dict[str, Type[BasePlugin]] = {} + self._manifests: Dict[str, dict] = {} + + def _is_strict_mode(self, app: Optional[Flask]) -> bool: + """Return True if loader should re-raise instead of isolating failures.""" + if app is None: + return True + return bool(app.config.get('DEBUG') or app.config.get('TESTING')) + + def _handle_failure(self, app: Optional[Flask], exc: Exception, name: str) -> None: + """In strict mode re-raise; in production log with context and continue.""" + if self._is_strict_mode(app): + raise exc + logger.exception(f'Plugin {name} failed to load: {exc}') + + def discover_plugins(self) -> List[str]: + """Discover available plugins in plugins directory.""" + available = [] + if not self.plugins_dir.exists(): + return available + + for item in self.plugins_dir.iterdir(): + if not item.is_dir(): + continue + if (item / 'plugin.py').exists(): + available.append(item.name) + + return available + + def load_manifest(self, name: str) -> dict: + """Load the plugin's manifest.json. + + Raises PluginNotFoundError if the manifest does not exist or is + unparseable. The result is cached. + """ + if name in self._manifests: + return self._manifests[name] + + manifest_path = self.plugins_dir / name / 'manifest.json' + if not manifest_path.exists(): + raise PluginNotFoundError( + f'Plugin {name} has no manifest.json at {manifest_path}', + plugin_name=name, + ) + + try: + with open(manifest_path) as f: + manifest = json.load(f) + except json.JSONDecodeError as e: + raise PluginContractError( + f'Plugin {name} has invalid manifest.json: {e}', + plugin_name=name, + ) from e + + for required in ('name', 'version', 'description'): + if not manifest.get(required): + raise PluginContractError( + f'Plugin {name} manifest.json missing required field "{required}"', + plugin_name=name, + ) + + if manifest['name'] != name: + raise PluginContractError( + f'Plugin directory "{name}" does not match manifest name "{manifest["name"]}"', + plugin_name=name, + ) + + self._manifests[name] = manifest + return manifest + + def check_contract_version(self, name: str, contract_version: str) -> None: + """Verify the plugin's core_version range includes contract_version. + + Raises PluginVersionError on mismatch. + """ + manifest = self.load_manifest(name) + core_version_spec = manifest.get('core_version', '') + + if not core_version_spec: + return + + try: + specifier = SpecifierSet(core_version_spec) + framework_version = Version(contract_version) + except (InvalidSpecifier, InvalidVersion) as e: + raise PluginContractError( + f'Plugin {name} has invalid version specifier "{core_version_spec}": {e}', + plugin_name=name, + ) from e + + if framework_version not in specifier: + raise PluginVersionError( + f'Plugin {name} requires core_version {core_version_spec} ' + f'but framework is at {contract_version}', + plugin_name=name, + ) + + def load_plugin_class(self, name: str) -> Type[BasePlugin]: + """Import the plugin's plugin.py and return the BasePlugin subclass. + + Raises PluginNotFoundError if plugin.py is missing. + Raises PluginContractError if the module has no BasePlugin subclass + or import fails. + """ + if name in self._plugin_classes: + return self._plugin_classes[name] + + plugin_module_path = self.plugins_dir / name / 'plugin.py' + if not plugin_module_path.exists(): + raise PluginNotFoundError( + f'Plugin {name} plugin.py not found at {plugin_module_path}', + plugin_name=name, + ) + + try: + spec = importlib.util.spec_from_file_location( + f'plugins.{name}.plugin', plugin_module_path, + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + except Exception as e: + raise PluginContractError( + f'Plugin {name} import failed: {e}', + plugin_name=name, + ) from e + + for attr_name in dir(module): + attr = getattr(module, attr_name) + if (isinstance(attr, type) + and issubclass(attr, BasePlugin) + and attr is not BasePlugin): + self._plugin_classes[name] = attr + return attr + + raise PluginContractError( + f'Plugin {name} module has no BasePlugin subclass', + plugin_name=name, + ) + + def load_plugin(self, name: str, app: Flask, db) -> Optional[BasePlugin]: + """Load and instantiate a plugin. + + Returns the plugin instance on success. In strict mode (dev/test), + any failure raises. In production, returns None and logs the + failure with full context. + """ + if name in self._loaded_plugins: + return self._loaded_plugins[name] + + try: + from shopdb import __contract_version__ + + manifest = self.load_manifest(name) + self.check_contract_version(name, __contract_version__) + + for dep in manifest.get('dependencies', []): + if not self.registry.is_enabled(dep): + raise PluginDependencyError( + f'Plugin {name} requires {dep} which is not enabled', + plugin_name=name, + ) + + plugin_class = self.load_plugin_class(name) + plugin = plugin_class() + plugin.init_app(app, db) + + self._loaded_plugins[name] = plugin + return plugin + + except PluginError as e: + self._handle_failure(app, e, name) + return None + except Exception as e: + wrapped = PluginError( + f'Unexpected failure loading plugin {name}: {e}', + plugin_name=name, + ) + self._handle_failure(app, wrapped, name) + return None + + def load_enabled_plugins(self, app: Flask, db) -> Dict[str, BasePlugin]: + """Load all enabled plugins in dependency order.""" + loaded = {} + + enabled = self.registry.get_enabled_plugins() + sorted_plugins = self._sort_by_dependencies(enabled) + + for name in sorted_plugins: + plugin = self.load_plugin(name, app, db) + if plugin: + loaded[name] = plugin + logger.info(f'Loaded plugin: {name} v{plugin.meta.version}') + + return loaded + + def _sort_by_dependencies(self, plugin_names: List[str]) -> List[str]: + """Sort plugins so dependencies come first. + + Reads dependencies from manifest.json directly; does not + instantiate plugin classes during sort. + """ + sorted_list = [] + visited = set() + + def visit(name): + if name in visited: + return + visited.add(name) + + try: + manifest = self.load_manifest(name) + for dep in manifest.get('dependencies', []): + if dep in plugin_names: + visit(dep) + except PluginError: + pass + + sorted_list.append(name) + + for name in plugin_names: + visit(name) + + return sorted_list + + def get_loaded_plugin(self, name: str) -> Optional[BasePlugin]: + """Get an already loaded plugin.""" + return self._loaded_plugins.get(name) + + def get_all_loaded(self) -> Dict[str, BasePlugin]: + """Get all loaded plugins.""" + return self._loaded_plugins.copy() diff --git a/shopdb/plugins/migrations.py b/shopdb/plugins/migrations.py new file mode 100644 index 00000000..77da4fba --- /dev/null +++ b/shopdb/plugins/migrations.py @@ -0,0 +1,207 @@ +"""Plugin migration management using Alembic.""" + +from pathlib import Path +from typing import Optional +import logging +import subprocess +import sys + +logger = logging.getLogger(__name__) + + +class PluginMigrationManager: + """ + Manages database migrations for plugins. + Each plugin has its own migrations directory. + """ + + def __init__(self, plugins_dir: Path, database_url: str): + self.plugins_dir = plugins_dir + self.database_url = database_url + + def get_migrations_dir(self, plugin_name: str) -> Optional[Path]: + """Get migrations directory for a plugin.""" + migrations_dir = self.plugins_dir / plugin_name / 'migrations' + if migrations_dir.exists(): + return migrations_dir + return None + + def run_plugin_migrations( + self, + plugin_name: str, + revision: str = 'head' + ) -> bool: + """ + Run migrations for a plugin. + + Uses flask db upgrade with the plugin's migrations directory. + """ + migrations_dir = self.get_migrations_dir(plugin_name) + + if not migrations_dir: + logger.info(f"No migrations directory for plugin {plugin_name}") + return True # No migrations to run + + try: + # Use alembic directly with plugin's migrations + from alembic.config import Config + from alembic import command + + config = Config() + config.set_main_option('script_location', str(migrations_dir)) + config.set_main_option('sqlalchemy.url', self.database_url) + + # Use plugin-specific version table + config.set_main_option( + 'version_table', + f'alembic_version_{plugin_name}' + ) + + command.upgrade(config, revision) + logger.info(f"Migrations completed for {plugin_name}") + return True + + except ImportError: + # Fallback to subprocess if alembic not available in context + logger.warning("Using subprocess for migrations") + return self._run_migrations_subprocess(plugin_name, revision) + + except Exception as e: + logger.error(f"Migration failed for {plugin_name}: {e}") + return False + + def _run_migrations_subprocess( + self, + plugin_name: str, + revision: str = 'head' + ) -> bool: + """Run migrations via subprocess as fallback.""" + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir: + return True + + try: + result = subprocess.run( + [ + sys.executable, '-m', 'alembic', + '-c', str(migrations_dir / 'alembic.ini'), + 'upgrade', revision + ], + capture_output=True, + text=True, + env={ + **dict(__import__('os').environ), + 'DATABASE_URL': self.database_url + } + ) + + if result.returncode != 0: + logger.error(f"Migration error: {result.stderr}") + return False + + return True + + except Exception as e: + logger.error(f"Migration subprocess failed: {e}") + return False + + def downgrade_plugin( + self, + plugin_name: str, + revision: str = 'base' + ) -> bool: + """ + Downgrade/rollback plugin migrations. + """ + migrations_dir = self.get_migrations_dir(plugin_name) + + if not migrations_dir: + return True + + try: + from alembic.config import Config + from alembic import command + + config = Config() + config.set_main_option('script_location', str(migrations_dir)) + config.set_main_option('sqlalchemy.url', self.database_url) + config.set_main_option( + 'version_table', + f'alembic_version_{plugin_name}' + ) + + command.downgrade(config, revision) + logger.info(f"Downgrade completed for {plugin_name}") + return True + + except Exception as e: + logger.error(f"Downgrade failed for {plugin_name}: {e}") + return False + + def get_current_revision(self, plugin_name: str) -> Optional[str]: + """Get current migration revision for a plugin.""" + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir: + return None + + try: + from alembic.config import Config + from alembic.script import ScriptDirectory + + config = Config() + config.set_main_option('script_location', str(migrations_dir)) + + script = ScriptDirectory.from_config(config) + return script.get_current_head() + + except Exception: + return None + + def has_pending_migrations(self, plugin_name: str) -> bool: + """Check if plugin has any migration scripts on disk. + + File-level check only (no DB): True when the plugin ships a + migrations/versions dir with at least one script. Used by + upgrade-all to decide whether the plugin participates in the + per-plugin Alembic flow at all. + """ + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir: + return False + + versions_dir = migrations_dir / 'versions' + if not versions_dir.exists(): + return False + + # Any migration files on disk? + migration_files = list(versions_dir.glob('*.py')) + return len(migration_files) > 0 + + def get_applied_revision(self, plugin_name: str) -> Optional[str]: + """Return the revision stamped in alembic_version_ in the DB, + or None if the plugin chain has never been stamped (or on any error).""" + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir or not self.database_url: + return None + try: + from sqlalchemy import create_engine + from alembic.runtime.migration import MigrationContext + + engine = create_engine(self.database_url) + with engine.connect() as connection: + context = MigrationContext.configure( + connection, + opts={'version_table': f'alembic_version_{plugin_name}'}, + ) + return context.get_current_revision() + except Exception: + return None + + def has_unapplied_migrations(self, plugin_name: str) -> bool: + """True when the plugin's on-disk chain head is ahead of what the DB + has stamped. Compares the script head against alembic_version_. + Best-effort: returns False when it cannot tell (no chain / no DB).""" + head = self.get_current_revision(plugin_name) # script head on disk + if not head: + return False + return self.get_applied_revision(plugin_name) != head diff --git a/shopdb/plugins/registry.py b/shopdb/plugins/registry.py new file mode 100644 index 00000000..322be821 --- /dev/null +++ b/shopdb/plugins/registry.py @@ -0,0 +1,140 @@ +"""Plugin registry for tracking installed and enabled plugins.""" + +import json +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone + + +@dataclass +class PluginState: + """Persistent state for a plugin.""" + + name: str + version: str + installed_at: str + enabled: bool = True + migrations_applied: List[str] = field(default_factory=list) + config: Dict = field(default_factory=dict) + + +class PluginRegistry: + """ + Manages plugin state persistence. + Stores state in JSON file in instance folder. + """ + + def __init__(self, state_file: Path): + self.state_file = state_file + self._plugins: Dict[str, PluginState] = {} + self._load() + + def _load(self) -> None: + """Load registry from file.""" + if self.state_file.exists(): + try: + with open(self.state_file, 'r') as f: + data = json.load(f) + for name, state_data in data.get('plugins', {}).items(): + self._plugins[name] = PluginState(**state_data) + except (json.JSONDecodeError, TypeError): + # Corrupted file, start fresh + self._plugins = {} + self._migrate_renamed_plugins() + + def _migrate_renamed_plugins(self) -> None: + """Upgrade path for the equipment -> machines plugin rename. + + Existing installs carry an 'equipment' entry in plugins.json. When the + renamed plugins/machines directory exists and no 'machines' entry does, + carry the state over under the new name and persist. + """ + if 'equipment' not in self._plugins or 'machines' in self._plugins: + return + machines_dir = Path(__file__).resolve().parents[2] / 'plugins' / 'machines' + if not machines_dir.exists(): + return + state = self._plugins.pop('equipment') + state.name = 'machines' + self._plugins['machines'] = state + self._save() + + def _save(self) -> None: + """Save registry to file.""" + self.state_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.state_file, 'w') as f: + json.dump({ + 'plugins': { + name: asdict(state) + for name, state in self._plugins.items() + } + }, f, indent=2) + + def register(self, name: str, version: str, enabled: bool = True) -> PluginState: + """Register a newly installed plugin. enabled=False leaves it off until a + site opts in (used for plugins that provision extra tables).""" + state = PluginState( + name=name, + version=version, + installed_at=datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), + enabled=enabled + ) + self._plugins[name] = state + self._save() + return state + + def unregister(self, name: str) -> None: + """Remove plugin from registry.""" + if name in self._plugins: + del self._plugins[name] + self._save() + + def get(self, name: str) -> Optional[PluginState]: + """Get plugin state.""" + return self._plugins.get(name) + + def is_installed(self, name: str) -> bool: + """Check if plugin is installed.""" + return name in self._plugins + + def is_enabled(self, name: str) -> bool: + """Check if plugin is enabled.""" + state = self._plugins.get(name) + return state.enabled if state else False + + def enable(self, name: str) -> None: + """Enable a plugin.""" + if name in self._plugins: + self._plugins[name].enabled = True + self._save() + + def disable(self, name: str) -> None: + """Disable a plugin.""" + if name in self._plugins: + self._plugins[name].enabled = False + self._save() + + def get_enabled_plugins(self) -> List[str]: + """Get list of enabled plugin names.""" + return [ + name for name, state in self._plugins.items() + if state.enabled + ] + + def add_migration(self, name: str, revision: str) -> None: + """Record that a migration was applied.""" + if name in self._plugins: + if revision not in self._plugins[name].migrations_applied: + self._plugins[name].migrations_applied.append(revision) + self._save() + + def get_all(self) -> Dict[str, PluginState]: + """Get all registered plugins.""" + return self._plugins.copy() + + def update_config(self, name: str, config: Dict) -> None: + """Update plugin configuration.""" + if name in self._plugins: + self._plugins[name].config.update(config) + self._save() diff --git a/shopdb/plugins/scaffolder.py b/shopdb/plugins/scaffolder.py new file mode 100644 index 00000000..ea6eebe7 --- /dev/null +++ b/shopdb/plugins/scaffolder.py @@ -0,0 +1,236 @@ +"""Plugin scaffolder. + +Generates a new plugin skeleton from the templates under +shopdb/plugins/templates/. The generated plugin satisfies the framework +contract out of the box; tests/test_plugin_scaffold.py is the canary +that this stays true as the contract evolves. +""" + +import re +from pathlib import Path +from string import Template +from typing import Optional + + +TEMPLATE_ROOT = Path(__file__).parent / 'templates' + +VALID_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9]*$') + +RESERVED_NAMES = { + 'plugin', 'plugins', 'shopdb', 'core', 'api', 'tests', 'templates', + 'schemas', 'models', 'frontend', 'docs', 'migrations', 'scripts', +} + + +class ScaffoldError(Exception): + """Raised when scaffolding cannot proceed.""" + + +def validate_name(name: str) -> None: + """Validate plugin name against CONTRIBUTING.md and reserved list. + + Raises ScaffoldError on any violation. + """ + if not name: + raise ScaffoldError('Plugin name is required') + + if not VALID_NAME_PATTERN.match(name): + raise ScaffoldError( + f'Plugin name "{name}" must be lowercase letters and digits only, ' + f'starting with a letter (no hyphens, underscores, or special chars). ' + f'See CONTRIBUTING.md for the naming convention.' + ) + + if name in RESERVED_NAMES: + raise ScaffoldError( + f'Plugin name "{name}" is reserved. Pick a different name.' + ) + + +def pascal_case(name: str) -> str: + """Convert lowercase plugin name to PascalCase class name. + + 'cameras' -> 'Cameras'. The convention assumes single-word lowercase + plugin names per the naming rules in CONTRIBUTING.md, so this is + just title-casing. + """ + return name[:1].upper() + name[1:] + + +def _render_template( + template_path: Path, + out_path: Path, + substitutions: dict, + overwrite: bool, +) -> bool: + """Render one template to out_path. + + Skips silently when out_path already exists and overwrite is False, so a + scaffold never clobbers a file the author has already edited. Returns True + when the file was written, False when it was skipped. + """ + if out_path.exists() and not overwrite: + return False + out_path.parent.mkdir(parents=True, exist_ok=True) + body = template_path.read_text() + out_path.write_text(Template(body).safe_substitute(substitutions)) + return True + + +def _scaffold_frontend( + name: str, + substitutions: dict, + plugin_target: Path, + frontend_dir: Path, + template_root: Path, + overwrite: bool, +) -> None: + """Render the frontend starting points for a scaffolded plugin. + + Writes the paste-in api-client snippet into the plugin directory, then the + Vue views and the router route file into the real frontend tree. The + snippet is emitted regardless of whether the frontend tree exists, because + it is a plugin-directory artifact useful even for external-repo plugins. + Views and the route file are skipped when frontend_dir is missing, which is + the normal case for a plugin developed in its own repository. + + Ordering matters: every view is written before the route file. A route file + that lazy-imports a view that is not on disk crashes the Vite dev server, + so the views must land first. + """ + fe_templates = template_root / 'frontend' + if not fe_templates.exists(): + return + + plugin_name = substitutions['Name'] + + # snippet lands in the plugin dir; author pastes it into api/index.js + snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl' + if snippet_template.exists(): + _render_template( + snippet_template, + plugin_target / 'frontend-api-snippet.js', + substitutions, + overwrite, + ) + + # views and route need the real frontend tree; external repos skip these + if not frontend_dir.exists(): + return + + views_dir = frontend_dir / 'views' / name + + # views first: route file lazy-imports them, missing views 500 vite + view_templates = { + 'List.vue.tmpl': f'{plugin_name}List.vue', + 'Detail.vue.tmpl': f'{plugin_name}Detail.vue', + 'Form.vue.tmpl': f'{plugin_name}Form.vue', + } + for template_name, out_name in view_templates.items(): + template_path = fe_templates / 'views' / template_name + if template_path.exists(): + _render_template( + template_path, + views_dir / out_name, + substitutions, + overwrite, + ) + + # route file last: all referenced views now exist on disk + route_template = fe_templates / 'routes.js.tmpl' + if route_template.exists(): + _render_template( + route_template, + frontend_dir / 'router' / 'routes' / f'{name}.js', + substitutions, + overwrite, + ) + + +def scaffold_plugin( + name: str, + description: str, + plugins_dir: Path, + template_root: Optional[Path] = None, + overwrite: bool = False, + frontend: bool = True, + frontend_dir: Optional[Path] = None, +) -> Path: + """Generate a new plugin from templates. + + Args: + name: Plugin name (lowercase, single word) + description: One-sentence description for manifest.json + README + plugins_dir: Target plugins directory (e.g., /plugins) + template_root: Override template source dir (default: bundled templates) + overwrite: If True, overwrite an existing plugin directory and any + existing generated frontend files + frontend: If True, also render the Vue frontend starting points (list, + detail, form views, a route file, and a paste-in api-client snippet) + frontend_dir: Frontend src directory to render views/routes into + (default: /../frontend/src). Views and the route file + are skipped when this directory does not exist, which is the normal + case for a plugin developed in its own repository. + + Returns: + Path to the generated plugin directory. + + Raises: + ScaffoldError on validation failure or when target exists and + overwrite is False. + """ + validate_name(name) + + template_root = template_root or TEMPLATE_ROOT + if not template_root.exists(): + raise ScaffoldError(f'Template root not found: {template_root}') + + target = plugins_dir / name + + if target.exists(): + if not overwrite: + raise ScaffoldError( + f'Plugin directory already exists: {target}. ' + f'Pass overwrite=True or remove it first.' + ) + + substitutions = { + 'name': name, + 'Name': pascal_case(name), + 'description': description, + } + + target.mkdir(parents=True, exist_ok=True) + + for template_path in template_root.rglob('*.tmpl'): + rel = template_path.relative_to(template_root) + + # frontend templates render into the frontend tree, not the plugin dir + if rel.parts and rel.parts[0] == 'frontend': + continue + + out_rel_str = str(rel.with_suffix('')) + if 'model.py' in out_rel_str: + out_rel_str = out_rel_str.replace('model.py', f'{name}.py') + + out_path = target / out_rel_str + out_path.parent.mkdir(parents=True, exist_ok=True) + + body = template_path.read_text() + rendered = Template(body).safe_substitute(substitutions) + + out_path.write_text(rendered) + + if frontend: + if frontend_dir is None: + frontend_dir = plugins_dir.parent / 'frontend' / 'src' + _scaffold_frontend( + name=name, + substitutions=substitutions, + plugin_target=target, + frontend_dir=Path(frontend_dir), + template_root=template_root, + overwrite=overwrite, + ) + + return target diff --git a/shopdb/plugins/templates/README.md.tmpl b/shopdb/plugins/templates/README.md.tmpl new file mode 100644 index 00000000..7b0c439b --- /dev/null +++ b/shopdb/plugins/templates/README.md.tmpl @@ -0,0 +1,43 @@ +# $Name plugin + +$description + +This plugin was generated by `flask plugin new $name`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain. + +## What's here + +- `plugin.py` - the `${Name}Plugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data. +- `models/$name.py` - example Asset extension table. Replace `examplefield` with your domain fields. +- `api/routes.py` - example list and detail endpoints. Add CRUD as needed. +- `schemas/__init__.py` - marshmallow schema stub for request/response validation. +- `tests/test_plugin.py` - smoke tests asserting contract compliance. +- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad. + +## Common edits + +| You want to... | Do this | +|---|---| +| Add a hook (search, navigation, dashboard widget) | Override the method in `${Name}Plugin`. See the PLUGIN-HOOKS wiki page. | +| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. | +| Add another model | Create `models/.py`, export it in `models/__init__.py`, return it in `get_models()`. | +| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. | + +## Frontend + +Vue components for this plugin live under `frontend/src/views/$name/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. + +## Install and run + +```bash +flask plugin install $name +flask db migrate -m "Add $name plugin tables" +flask db upgrade +pytest plugins/$name/tests/ +``` + +## References + +- the PLUGIN-HOOKS wiki page - canonical hook reference +- the PLUGIN-QUICKSTART wiki page - 30-minute walkthrough +- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract +- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules diff --git a/shopdb/plugins/templates/__init__.py.tmpl b/shopdb/plugins/templates/__init__.py.tmpl new file mode 100644 index 00000000..61eebb58 --- /dev/null +++ b/shopdb/plugins/templates/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""$Name plugin package.""" + +from .plugin import ${Name}Plugin + +__all__ = ['${Name}Plugin'] diff --git a/shopdb/plugins/templates/api/__init__.py.tmpl b/shopdb/plugins/templates/api/__init__.py.tmpl new file mode 100644 index 00000000..a2e38820 --- /dev/null +++ b/shopdb/plugins/templates/api/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""$Name plugin API package.""" + +from .routes import ${name}_bp + +__all__ = ['${name}_bp'] diff --git a/shopdb/plugins/templates/api/routes.py.tmpl b/shopdb/plugins/templates/api/routes.py.tmpl new file mode 100644 index 00000000..7460022e --- /dev/null +++ b/shopdb/plugins/templates/api/routes.py.tmpl @@ -0,0 +1,45 @@ +"""$Name plugin API routes.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + success_response, + error_response, + paginated_response, + ErrorCodes, + get_pagination_params, + paginate_query, +) + +from ..models import $Name + + +${name}_bp = Blueprint('$name', __name__) + + +@${name}_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_${name}(): + """List $name assets, paginated.""" + page, per_page = get_pagination_params(request) + + query = ${Name}.query + items, total = paginate_query(query, page, per_page) + data = [item.to_dict() for item in items] + + return paginated_response(data, page, per_page, total) + + +@${name}_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_${name}(assetid: int): + """Get a single $name by assetid.""" + item = ${Name}.query.get(assetid) + if not item: + return error_response( + ErrorCodes.NOT_FOUND, + f'$Name with assetid {assetid} not found', + http_code=404, + ) + return success_response(item.to_dict()) diff --git a/shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl b/shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl new file mode 100644 index 00000000..58776494 --- /dev/null +++ b/shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl @@ -0,0 +1,35 @@ +/* + * ${Name} API client snippet. + * + * Paste this ${name}Api block into frontend/src/api/index.js (next to the + * other per-resource blocks). Then, in the generated ${Name}List, ${Name}Detail, + * and ${Name}Form views, delete the local ${name}Api const and import the shared + * one instead: + * + * import { ${name}Api } from '../../api' + * + * The scaffolded views ship with an identical inline client so they build and + * run before you touch the shared api module. This file is NOT auto-merged into + * api/index.js on purpose; that module is hand-maintained and shared. + * + * The create/update/delete calls assume matching POST/PUT/DELETE routes exist + * on the backend. The scaffolded api/routes.py only ships list and get; add the + * write endpoints when you wire up the form. + */ +export const ${name}Api = { + list(params = {}) { + return api.get('/${name}', { params }) + }, + get(itemId) { + return api.get(`/${name}/${itemId}`) + }, + create(data) { + return api.post('/${name}', data) + }, + update(itemId, data) { + return api.put(`/${name}/${itemId}`, data) + }, + remove(itemId) { + return api.delete(`/${name}/${itemId}`) + } +} diff --git a/shopdb/plugins/templates/frontend/routes.js.tmpl b/shopdb/plugins/templates/frontend/routes.js.tmpl new file mode 100644 index 00000000..758b6f70 --- /dev/null +++ b/shopdb/plugins/templates/frontend/routes.js.tmpl @@ -0,0 +1,35 @@ +/** + * ${Name} plugin routes. + * + * Auto-discovered by the router via import.meta.glob, so no registration + * edit is needed. Every route carries meta.plugin '${name}' so the ADR-009 + * guard redirects to the dashboard when the ${name} backend plugin is + * disabled. Form routes add requiresAuth so anonymous users cannot reach + * create or edit. + */ +export default [ + { + path: '${name}', + name: '${name}', + component: () => import('../../views/${name}/${Name}List.vue'), + meta: { plugin: '${name}' } + }, + { + path: '${name}/new', + name: '${name}-new', + component: () => import('../../views/${name}/${Name}Form.vue'), + meta: { requiresAuth: true, plugin: '${name}' } + }, + { + path: '${name}/:id', + name: '${name}-detail', + component: () => import('../../views/${name}/${Name}Detail.vue'), + meta: { plugin: '${name}' } + }, + { + path: '${name}/:id/edit', + name: '${name}-edit', + component: () => import('../../views/${name}/${Name}Form.vue'), + meta: { requiresAuth: true, plugin: '${name}' } + } +] diff --git a/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl b/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl new file mode 100644 index 00000000..9777a799 --- /dev/null +++ b/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl @@ -0,0 +1,149 @@ + + + + + diff --git a/shopdb/plugins/templates/frontend/views/Form.vue.tmpl b/shopdb/plugins/templates/frontend/views/Form.vue.tmpl new file mode 100644 index 00000000..a1a4031b --- /dev/null +++ b/shopdb/plugins/templates/frontend/views/Form.vue.tmpl @@ -0,0 +1,228 @@ + + + + + diff --git a/shopdb/plugins/templates/frontend/views/List.vue.tmpl b/shopdb/plugins/templates/frontend/views/List.vue.tmpl new file mode 100644 index 00000000..bf324aae --- /dev/null +++ b/shopdb/plugins/templates/frontend/views/List.vue.tmpl @@ -0,0 +1,142 @@ + + + + + diff --git a/shopdb/plugins/templates/manifest.json.tmpl b/shopdb/plugins/templates/manifest.json.tmpl new file mode 100644 index 00000000..7c8f9d0c --- /dev/null +++ b/shopdb/plugins/templates/manifest.json.tmpl @@ -0,0 +1,9 @@ +{ + "name": "$name", + "version": "0.1.0", + "description": "$description", + "author": "", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/$name" +} diff --git a/shopdb/plugins/templates/models/__init__.py.tmpl b/shopdb/plugins/templates/models/__init__.py.tmpl new file mode 100644 index 00000000..792db2cd --- /dev/null +++ b/shopdb/plugins/templates/models/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""$Name plugin models.""" + +from .$name import $Name + +__all__ = ['$Name'] diff --git a/shopdb/plugins/templates/models/model.py.tmpl b/shopdb/plugins/templates/models/model.py.tmpl new file mode 100644 index 00000000..5669bafb --- /dev/null +++ b/shopdb/plugins/templates/models/model.py.tmpl @@ -0,0 +1,32 @@ +"""$Name model. + +This is an Asset extension table keyed by assetid. The Asset row holds +the platform fields (assetnumber, name, vendorid, locationid, etc.); +this table holds the $name-specific fields. Replace the example fields +below with your domain model. +""" + +from shopdb.api import db, BaseModel + + +class $Name(BaseModel): + """$Name domain entity, extending Asset by assetid.""" + + __tablename__ = '$name' + + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + primary_key=True, + ) + + # TODO: replace these example fields with your domain fields. + examplefield = db.Column(db.String(255), nullable=True) + + asset = db.relationship('Asset', backref=db.backref('$name', uselist=False)) + + def to_dict(self): + return { + 'assetid': self.assetid, + 'examplefield': self.examplefield, + } diff --git a/shopdb/plugins/templates/plugin.py.tmpl b/shopdb/plugins/templates/plugin.py.tmpl new file mode 100644 index 00000000..2daf77c2 --- /dev/null +++ b/shopdb/plugins/templates/plugin.py.tmpl @@ -0,0 +1,70 @@ +"""$Name plugin main class. + +$description +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, AssetType + +from .models import $Name +from .api import ${name}_bp + +logger = logging.getLogger(__name__) + + +class ${Name}Plugin(BasePlugin): + """$Name plugin. + + $description + """ + + def __init__(self): + manifest_path = Path(__file__).parent / 'manifest.json' + with open(manifest_path) as f: + self._manifest = json.load(f) + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest['name'], + version=self._manifest['version'], + description=self._manifest['description'], + author=self._manifest.get('author', ''), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0'), + api_prefix=self._manifest.get('api_prefix'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return ${name}_bp + + def get_models(self) -> List[Type]: + return [$Name] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f'$Name plugin initialized (v{self.meta.version})') + + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._ensure_asset_type() + logger.info('$Name plugin installed') + + def _ensure_asset_type(self) -> None: + existing = AssetType.query.filter_by(assettype='$name').first() + if not existing: + asset_type = AssetType( + assettype='$name', + pluginname='$name', + tablename='$name', + description=self.meta.description, + ) + db.session.add(asset_type) + db.session.commit() + logger.debug('Created asset type: $name') diff --git a/shopdb/plugins/templates/schemas/__init__.py.tmpl b/shopdb/plugins/templates/schemas/__init__.py.tmpl new file mode 100644 index 00000000..da566884 --- /dev/null +++ b/shopdb/plugins/templates/schemas/__init__.py.tmpl @@ -0,0 +1,6 @@ +"""$Name plugin schemas (marshmallow). + +Add schema classes here when you need request/response validation +beyond the simple to_dict() output. The framework wires marshmallow +into the response helpers; see the PLUGIN-HOOKS wiki page for details. +""" diff --git a/shopdb/plugins/templates/tests/__init__.py.tmpl b/shopdb/plugins/templates/tests/__init__.py.tmpl new file mode 100644 index 00000000..e69de29b diff --git a/shopdb/plugins/templates/tests/test_plugin.py.tmpl b/shopdb/plugins/templates/tests/test_plugin.py.tmpl new file mode 100644 index 00000000..295af38c --- /dev/null +++ b/shopdb/plugins/templates/tests/test_plugin.py.tmpl @@ -0,0 +1,30 @@ +"""$Name plugin smoke tests. + +Asserts the plugin loads cleanly and satisfies the framework contract. +Replace and extend with domain tests as you build the plugin out. +""" + +from plugins.$name.plugin import ${Name}Plugin + + +def test_${name}_plugin_meta_is_valid(): + """${Name}Plugin.meta returns a PluginMeta with the expected name.""" + plugin = ${Name}Plugin() + assert plugin.meta.name == '$name' + assert plugin.meta.api_prefix == '/api/$name' + + +def test_${name}_plugin_get_blueprint_returns_blueprint(): + """get_blueprint returns a Flask Blueprint, not None.""" + from flask import Blueprint + plugin = ${Name}Plugin() + assert isinstance(plugin.get_blueprint(), Blueprint) + + +def test_${name}_plugin_get_models_returns_a_model(): + """get_models returns a list with at least one SQLAlchemy model.""" + plugin = ${Name}Plugin() + models = plugin.get_models() + assert len(models) >= 1 + for model in models: + assert hasattr(model, '__tablename__') diff --git a/shopdb/static/images/floorplan-placeholder.svg b/shopdb/static/images/floorplan-placeholder.svg new file mode 100644 index 00000000..e33d54a4 --- /dev/null +++ b/shopdb/static/images/floorplan-placeholder.svg @@ -0,0 +1,11 @@ + + + + + + + + + + Upload your facility floor plan in Settings > Map + diff --git a/shopdb/utils/__init__.py b/shopdb/utils/__init__.py new file mode 100644 index 00000000..93edb233 --- /dev/null +++ b/shopdb/utils/__init__.py @@ -0,0 +1,20 @@ +"""Utility modules.""" + +from .responses import ( + api_response, + success_response, + error_response, + paginated_response, + ErrorCodes +) +from .pagination import get_pagination_params, paginate_query + +__all__ = [ + 'api_response', + 'success_response', + 'error_response', + 'paginated_response', + 'ErrorCodes', + 'get_pagination_params', + 'paginate_query' +] diff --git a/shopdb/utils/apitoken_auth.py b/shopdb/utils/apitoken_auth.py new file mode 100644 index 00000000..99ae203e --- /dev/null +++ b/shopdb/utils/apitoken_auth.py @@ -0,0 +1,153 @@ +"""Personal API token (PAT) authentication shim. + +A request may send `Authorization: Bearer shopdb_pat_...`. This is recognized +BEFORE any JWT decode: a before_request hook validates the PAT (hash lookup, +active, not expired, active owner) and, on success, mints a short internal +request-scoped JWT for the token's user and swaps it into the request's +Authorization header. + +Why mint a JWT instead of only stashing the user on g: every write route in +this app stacks a mandatory @jwt_required() ABOVE @require_permission / +@require_role. That mandatory decorator decodes the Authorization header +itself, so the ONLY way a PAT reaches the whole existing auth+authz stack +(jwt_required, require_permission, require_role, import_mode, current_user, +get_jwt_identity) unchanged is to present a genuine JWT downstream. The minted +token lives only in this request's environ and is never returned to the client. + +Result: a PAT authenticates any route a login JWT would, acting as its owner, +with zero changes to the authz decorators or import-mode helpers. +""" + +from datetime import datetime, timezone + +from flask import g, request +from flask_jwt_extended import create_access_token + +from shopdb.extensions import db +from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX +from shopdb.utils.responses import error_response, ErrorCodes + + +# Only rewrite lastusedat when it is older than this, to avoid a DB write on +# every single request a busy integration makes. +_LASTUSED_THROTTLE_SECONDS = 60 + + +def _utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _extract_pat_secret(): + """Return the PAT secret from the Authorization header, or None.""" + header = request.headers.get('Authorization', '') + parts = header.split() + if len(parts) == 2 and parts[0] == 'Bearer' \ + and parts[1].startswith(TOKEN_SECRET_PREFIX): + return parts[1] + return None + + +def resolve_api_token(secret): + """Validate a PAT secret. Return (token, user) or None. + + Shared validator: hash lookup, active token, unexpired, active owner. The + before_request shim and the collector API (which does not decode JWT) both + call this so the checks live in one place. + """ + from shopdb.core.models import User + + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret), isactive=True).first() + if token is None or token.is_expired: + return None + user = db.session.get(User, token.userid) + if user is None or not user.isactive: + return None + return token, user + + +def touch_apitoken_lastused(token): + """Throttled lastusedat write. Independent commit; nothing else is pending + this early in the request, so it cannot clobber route work.""" + now = _utcnow() + if token.lastusedat is None \ + or (now - token.lastusedat).total_seconds() > _LASTUSED_THROTTLE_SECONDS: + token.lastusedat = now + db.session.commit() + + +def service_token_authorized(scope): + """True when the current request carries a managed token scoped for `scope` + whose owner is active and holds that permission. Accepts X-API-Key or a + Bearer PAT (the before_request shim resolves Bearer into g.apitokenid). + Touches lastusedat on success. + + The single contract-surface entry point for unattended SERVICE tokens + (collector.ingest, geenforce.fetch, ...), so plugins authorize a service + token without reaching into core token internals. Returns False on any + miss; the caller returns its own 401. + """ + from shopdb.core.models import User + + api_key = request.headers.get('X-API-Key') + token = None + if api_key and api_key.startswith(TOKEN_SECRET_PREFIX): + resolved = resolve_api_token(api_key) + token = resolved[0] if resolved else None + else: + tokenid = getattr(g, 'apitokenid', None) + if tokenid is not None: + token = db.session.get(ApiToken, tokenid) + if token is None: + return False + scopelist = token.scopelist + if not scopelist or scope not in scopelist: + return False + user = db.session.get(User, token.userid) + if user is None or not user.isactive or not user.haspermission(scope): + return False + touch_apitoken_lastused(token) + return True + + +def install_apitoken_auth(app): + """Register the before_request PAT shim on the app.""" + + @app.before_request + def _apitoken_before_request(): + secret = _extract_pat_secret() + if secret is None: + return + resolved = resolve_api_token(secret) + if resolved is None: + # The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it + # is unknown, revoked, or expired. Reject with a clear 401 instead + # of letting the JWT decoder emit a confusing 422 on the non-JWT. + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Invalid, revoked, or expired API token', + http_code=401) + token, user = resolved + + # Read claim inputs before the (possible) commit expires the instance. + claims = { + 'username': user.username, + 'roles': [role.rolename for role in user.roles], + } + # A scoped token carries a patscopes claim; authz reads it to grant ONLY + # the listed permissions and to deny role gates + import mode. Unscoped + # tokens carry no such claim and mint exactly as a login JWT would. + scopelist = token.scopelist + if scopelist is not None: + claims['patscopes'] = scopelist + # Expose the token/user for audit and introspection if a handler wants it. + g.apitokenid = token.tokenid + g.apitokenuser = user + + touch_apitoken_lastused(token) + + # Mint a request-scoped JWT for the owner and swap it into the header + # so the whole downstream auth stack authenticates as that user. + access_token = create_access_token( + identity=str(user.userid), additional_claims=claims) + request.environ['HTTP_AUTHORIZATION'] = f'Bearer {access_token}' diff --git a/shopdb/utils/authz.py b/shopdb/utils/authz.py new file mode 100644 index 00000000..a8964d98 --- /dev/null +++ b/shopdb/utils/authz.py @@ -0,0 +1,106 @@ +"""Authorization decorators for role and permission gating. + +Authentication (is the caller logged in?) is handled by Flask-JWT-Extended's +@jwt_required. Authorization (is the caller ALLOWED to do this?) is handled +here. The two are separate concerns; a route needs both on any state-changing +action. + +These decorators call verify_jwt_in_request() themselves, so they work whether +or not a separate @jwt_required is also present. The admin role bypasses every +permission check (see User.haspermission), so an admin never needs individual +permissions granted. + +Usage: + + @assets_bp.route('/', methods=['DELETE']) + @jwt_required() + @require_permission('assets.delete') + def delete_asset(asset_id): + ... +""" + +from functools import wraps + +from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt + +from shopdb.utils.responses import error_response, ErrorCodes + + +def require_permission(permission_name: str): + """Gate a route behind a single permission. Admin role bypasses. + + A scoped personal API token (patscopes claim present) does NOT bypass: it + grants only the permissions in its scope list, intersected with what the + owner actually holds. See shopdb/utils/apitoken_auth.py. + """ + def decorator(view_func): + @wraps(view_func) + def wrapper(*args, **kwargs): + # verify jwt first so current_user is loaded (idempotent if the + # route also has @jwt_required) + verify_jwt_in_request() + if current_user is None: + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Authentication required', + http_code=401 + ) + patscopes = get_jwt().get('patscopes') + if patscopes is not None: + # Scoped PAT: allowed only when this permission is in the scope + # list AND the owner actually holds it. haspermission still + # returns True for an admin owner (who legitimately holds + # everything), so the scope list is the real limiter - the + # admin bypass is suspended. + allowed = (permission_name in patscopes + and current_user.haspermission(permission_name)) + if not allowed: + return error_response( + ErrorCodes.FORBIDDEN, + 'This API token is not scoped for this action', + http_code=403 + ) + return view_func(*args, **kwargs) + if not current_user.haspermission(permission_name): + return error_response( + ErrorCodes.FORBIDDEN, + 'You do not have permission to perform this action', + http_code=403 + ) + return view_func(*args, **kwargs) + return wrapper + return decorator + + +def require_role(rolename: str): + """Gate a route behind a single role (e.g. 'admin'). + + A scoped personal API token (patscopes claim present) is ALWAYS denied here: + scopes gate individual permissions, not roles, so role-gated admin surfaces + require an unscoped token. See shopdb/utils/apitoken_auth.py. + """ + def decorator(view_func): + @wraps(view_func) + def wrapper(*args, **kwargs): + verify_jwt_in_request() + if current_user is None: + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Authentication required', + http_code=401 + ) + if get_jwt().get('patscopes') is not None: + return error_response( + ErrorCodes.FORBIDDEN, + 'Scoped API tokens cannot access role-gated endpoints', + http_code=403 + ) + if not current_user.hasrole(rolename): + return error_response( + ErrorCodes.FORBIDDEN, + f'{rolename.capitalize()} access required', + http_code=403 + ) + return view_func(*args, **kwargs) + return wrapper + return decorator diff --git a/shopdb/utils/cmmc_usb_db.py b/shopdb/utils/cmmc_usb_db.py new file mode 100644 index 00000000..d2c2ccc0 --- /dev/null +++ b/shopdb/utils/cmmc_usb_db.py @@ -0,0 +1,38 @@ +"""Connection helper for the CMMC USB check-in/out database. + +Credentials are pulled from app config (env-backed, see Config.CMMC_USB_DB_*), +never hardcoded. Used by the USB plugin to track device check-in/out against a +separate MySQL database (cmmc_usb), mirroring the read-only employee-directory +pattern in employee_db.py. Unlike the employee DB this one is read-write, so +callers commit their own writes and close the connection in a finally block. +""" + +import pymysql +from flask import current_app + + +def _setting_or_config(setting_key, config_key): + """Non-secret config: a saved Setting wins, else the env-backed app config. + + Lets the setup wizard edit host/name/user without touching .env, while the + password stays env-only. + """ + from shopdb.core.models import Setting + try: + row = Setting.query.filter_by(key=setting_key).first() + if row and row.value: + return row.value + except Exception: + pass + return current_app.config[config_key] + + +def cmmc_usb_connection(): + """Open a pymysql connection to the cmmc_usb DB.""" + return pymysql.connect( + host=_setting_or_config('cmmc_usb_db_host', 'CMMC_USB_DB_HOST'), + user=_setting_or_config('cmmc_usb_db_user', 'CMMC_USB_DB_USER'), + password=current_app.config['CMMC_USB_DB_PASSWORD'], + database=_setting_or_config('cmmc_usb_db_name', 'CMMC_USB_DB_NAME'), + cursorclass=pymysql.cursors.DictCursor, + ) diff --git a/shopdb/utils/employee_db.py b/shopdb/utils/employee_db.py new file mode 100644 index 00000000..78c789ef --- /dev/null +++ b/shopdb/utils/employee_db.py @@ -0,0 +1,36 @@ +"""Connection helper for the read-only employee directory database. + +Credentials are pulled from app config (env-backed, see Config.EMPLOYEE_DB_*), +never hardcoded. Used by the employee lookup API and the notification +recognition feature. +""" + +import pymysql +from flask import current_app + + +def _setting_or_config(setting_key, config_key): + """Non-secret config: a saved Setting wins, else the env-backed app config. + + Lets the setup wizard edit host/name/user without touching .env, while the + password stays env-only. + """ + from shopdb.core.models import Setting + try: + row = Setting.query.filter_by(key=setting_key).first() + if row and row.value: + return row.value + except Exception: + pass + return current_app.config[config_key] + + +def employee_connection(): + """Open a pymysql connection to the employee directory DB.""" + return pymysql.connect( + host=_setting_or_config('employee_db_host', 'EMPLOYEE_DB_HOST'), + user=_setting_or_config('employee_db_user', 'EMPLOYEE_DB_USER'), + password=current_app.config['EMPLOYEE_DB_PASSWORD'], + database=_setting_or_config('employee_db_name', 'EMPLOYEE_DB_NAME'), + cursorclass=pymysql.cursors.DictCursor, + ) diff --git a/shopdb/utils/import_mode.py b/shopdb/utils/import_mode.py new file mode 100644 index 00000000..221f9e67 --- /dev/null +++ b/shopdb/utils/import_mode.py @@ -0,0 +1,111 @@ +"""Import-mode helpers: let admins replay legacy history through the API. + +A migration script importing the classic ASP shopdb +needs two things the normal API withholds: + + 1. Preserve each row's original createddate/modifieddate instead of stamping + "now" on insert/update. + 2. Backdate event history such as USB checkouts to when they really happened. + +Both are gated. The caller must BOTH be an admin AND send the request header +`X-Import-Mode: true`. Outside import mode every helper here is a no-op, so +wiring a call into a normal create/update path never changes behavior for +regular users. See the IMPORT-API wiki page for the operator manual. +""" + +from datetime import datetime, timezone + +from flask import request +from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt + + +# Request header a migration client sets to opt a request into import mode. +IMPORT_MODE_HEADER = 'X-Import-Mode' + +# strptime formats accepted for import timestamps, tried in order. Covers the +# ISO 'T' form and the legacy MySQL 'space' form, with and without fractions, +# plus a bare date. +_IMPORT_DATETIME_FORMATS = ( + '%Y-%m-%dT%H:%M:%S.%f', + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%dT%H:%M', + '%Y-%m-%d %H:%M:%S.%f', + '%Y-%m-%d %H:%M:%S', + '%Y-%m-%d %H:%M', + '%Y-%m-%d', +) + + +def import_mode_active(): + """True when the request is an admin-authenticated import request. + + Needs header `X-Import-Mode: true` AND an admin caller. Safe to call from + any request context: verify_jwt_in_request is optional here so a missing or + bad token yields False instead of raising, and it is idempotent when the + route already ran @jwt_required.""" + header = (request.headers.get(IMPORT_MODE_HEADER) or '').strip().lower() + if header != 'true': + return False + verify_jwt_in_request(optional=True) + user = current_user + if user is None or not user.hasrole('admin'): + return False + # A scoped PAT never gets import mode: import mode is an admin-role + # capability and a scoped token suspends the admin bypass. get_jwt is safe + # here - an admin user means a valid JWT was decoded above. + if get_jwt().get('patscopes') is not None: + return False + return True + + +def parse_import_datetime(value): + """Parse an import timestamp into naive UTC, or None. + + Accepts ISO '2020-01-05T12:00:00', legacy 'YYYY-MM-DD HH:MM:SS', a bare + date, or an already-parsed datetime. A trailing 'Z' is treated as UTC. + Timezone-aware input is converted to UTC then stripped to naive, matching + the repo convention of storing naive-UTC in DB DateTime columns.""" + if value is None: + return None + if isinstance(value, datetime): + parsed = value + else: + text = str(value).strip() + if not text: + return None + if text[-1] in ('Z', 'z'): + text = text[:-1] + parsed = None + for fmt in _IMPORT_DATETIME_FORMATS: + try: + parsed = datetime.strptime(text, fmt) + break + except ValueError: + continue + if parsed is None: + # last resort: let fromisoformat try (handles offsets like +00:00) + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is not None: + parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + + +def apply_import_timestamps(instance, data): + """Stamp createddate/modifieddate on a row from the payload, in import mode. + + Call this in a create/update path after building the instance and before + commit. It is a no-op unless ALL hold: import mode is active (admin + + header), the payload carries the field, and the model actually has the + column. Explicitly setting modifieddate also suppresses the column's + onupdate=now default on updates, so legacy history survives edits too.""" + if not data or not import_mode_active(): + return + created = parse_import_datetime(data.get('createddate')) + if created is not None and hasattr(instance, 'createddate'): + instance.createddate = created + modified = parse_import_datetime(data.get('modifieddate')) + if modified is not None and hasattr(instance, 'modifieddate'): + instance.modifieddate = modified diff --git a/shopdb/utils/mailer.py b/shopdb/utils/mailer.py new file mode 100644 index 00000000..e6081021 --- /dev/null +++ b/shopdb/utils/mailer.py @@ -0,0 +1,259 @@ +"""Email sending service (stdlib smtplib/ssl/email only). + +Reads SMTP configuration settings-first (via the cached settings map) with an +environment-variable fallback when any SMTP_* env var is present. When email is +disabled or the host is unset the sender is a graceful no-op that logs a warning +and returns False, so an unconfigured site never crashes on a send attempt. + +Public helpers: + send_email(to, subject, html, text=None) -> bool + try_send(to, subject, html, text=None) -> (bool, error_or_None) + send_alert(subject, html, text=None) -> bool + render_email(title, body_html, intro=None) -> (html, text) + render_table_email(title, columns, rows, intro=None) -> (html, text) + +The SMTP password is never logged. +""" + +import os +import re +import smtplib +import ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formataddr + +from flask import current_app, has_app_context + +# Env var names that back each SMTP setting when settings are blank. +_ENV_MAP = { + 'smtp_host': 'SMTP_HOST', + 'smtp_port': 'SMTP_PORT', + 'smtp_username': 'SMTP_USERNAME', + 'smtp_password': 'SMTP_PASSWORD', + 'smtp_from_address': 'SMTP_FROM_ADDRESS', + 'smtp_from_name': 'SMTP_FROM_NAME', + 'alert_recipients': 'SMTP_ALERT_RECIPIENTS', +} + +# Connect/send timeout in seconds. Keeps a wedged relay from hanging a request. +_SMTP_TIMEOUT = 10 + + +def _log(): + """App logger when in an app context, else a module logger.""" + if has_app_context(): + return current_app.logger + import logging + return logging.getLogger('shopdb.mailer') + + +def _env_active(): + """True when the deployment supplies SMTP_* env overrides.""" + return any(k.startswith('SMTP_') for k in os.environ) + + +def get_smtp_config(): + """Resolve the SMTP config settings-first with env fallback. + + Returns a dict with typed fields. `enabled` is False when the site has not + turned email on; callers should treat that as a no-op signal. + """ + settings = {} + if has_app_context(): + # Local import avoids a circular import at module load. + from shopdb.core.api.settings import get_cached_settings + try: + settings = get_cached_settings() or {} + except Exception: + settings = {} + + env_active = _env_active() + + def pick(key, default=''): + val = settings.get(key) + if (val is None or val == '') and env_active: + val = os.environ.get(_ENV_MAP.get(key, ''), default) + return default if val is None else val + + enabled = bool(settings.get('smtp_enabled')) + if not enabled and env_active: + enabled = os.environ.get('SMTP_ENABLED', '').lower() in ('true', '1', 'yes') + + use_tls = settings.get('smtp_use_tls') + if use_tls is None: + if env_active: + use_tls = os.environ.get('SMTP_USE_TLS', 'true').lower() in ('true', '1', 'yes') + else: + use_tls = True + + try: + port = int(pick('smtp_port', 587) or 587) + except (ValueError, TypeError): + port = 587 + + return { + 'enabled': enabled, + 'host': pick('smtp_host'), + 'port': port, + 'username': pick('smtp_username'), + 'password': pick('smtp_password'), + 'use_tls': bool(use_tls), + 'from_address': pick('smtp_from_address'), + 'from_name': pick('smtp_from_name') or 'ShopDB', + 'alert_recipients': pick('alert_recipients'), + } + + +def _normalize_recipients(to): + """Coerce a recipient spec (string, comma/semicolon list, or iterable) to a + clean list of addresses.""" + if not to: + return [] + if isinstance(to, str): + parts = re.split(r'[,;]', to) + else: + parts = list(to) + return [p.strip() for p in parts if p and p.strip()] + + +def try_send(to, subject, html, text=None): + """Send an email. Returns (ok, error). + + ok is False with error=None when email is not configured (a graceful + no-op). ok is False with an error string when a real send failed. The SMTP + password is never included in the error. + """ + config = get_smtp_config() + recipients = _normalize_recipients(to) + + if not config['enabled'] or not config['host']: + _log().warning('Email not sent: SMTP is disabled or host is unset.') + return False, None + if not recipients: + _log().warning('Email not sent: no recipients.') + return False, 'No recipients specified' + if not config['from_address']: + _log().warning('Email not sent: from address is unset.') + return False, 'From address is not configured' + + message = MIMEMultipart('alternative') + message['Subject'] = subject + message['From'] = formataddr((config['from_name'], config['from_address'])) + message['To'] = ', '.join(recipients) + # Plaintext first so alternative-aware clients prefer the HTML part. + message.attach(MIMEText(text or _html_to_text(html), 'plain', 'utf-8')) + message.attach(MIMEText(html, 'html', 'utf-8')) + + try: + context = ssl.create_default_context() + if config['port'] == 465: + server = smtplib.SMTP_SSL( + config['host'], config['port'], + timeout=_SMTP_TIMEOUT, context=context) + else: + server = smtplib.SMTP( + config['host'], config['port'], timeout=_SMTP_TIMEOUT) + with server: + if config['port'] != 465 and config['use_tls']: + server.starttls(context=context) + if config['username']: + server.login(config['username'], config['password']) + server.sendmail(config['from_address'], recipients, message.as_string()) + _log().info('Email sent to %d recipient(s): %s', len(recipients), subject) + return True, None + except Exception as exception: + # Never let the password reach the log or the caller. + error = _scrub(str(exception), config['password']) + _log().error('Email send failed: %s', error) + return False, error + + +def send_email(to, subject, html, text=None): + """Send an email. Returns True on success, False otherwise (no-op safe).""" + ok, _error = try_send(to, subject, html, text=text) + return ok + + +def send_alert(subject, html, text=None): + """Send an alert to the site's configured alert_recipients. Returns False + when email is off or no alert recipients are configured.""" + config = get_smtp_config() + recipients = _normalize_recipients(config['alert_recipients']) + if not recipients: + _log().warning('Alert not sent: no alert_recipients configured.') + return False + return send_email(recipients, subject, html, text=text) + + +def _scrub(value, secret): + """Remove a secret substring from a string (defensive log hygiene).""" + if secret and secret in value: + return value.replace(secret, '***') + return value + + +def _html_to_text(html): + """Very small HTML-to-text fallback for the plaintext alternative.""" + text = re.sub(r'(?i)', '\n', html) + text = re.sub(r'(?i)', '\n', text) + text = re.sub(r'<[^>]+>', '', text) + text = re.sub(r'\n{3,}', '\n\n', text) + return text.strip() + + +def _escape(value): + """HTML-escape a cell value.""" + return (str('' if value is None else value) + .replace('&', '&').replace('<', '<').replace('>', '>')) + + +def render_email(title, body_html, intro=None): + """Wrap body HTML in a simple branded shell. Returns (html, text).""" + intro_html = f'

{_escape(intro)}

' if intro else '' + html = ( + '
' + f'

{_escape(title)}

' + f'{intro_html}{body_html}' + '
' + '

Sent by ShopDB.

' + '
' + ) + return html, _html_to_text(html) + + +def render_table_email(title, columns, rows, intro=None): + """Render tabular report data as an HTML table email. Returns (html, text). + + columns: list of {'key','label'} dicts or of plain strings. + rows: list of dicts keyed by the column keys. + """ + normalized = [] + for column in columns or []: + if isinstance(column, dict): + normalized.append((column.get('key'), column.get('label', column.get('key')))) + else: + normalized.append((column, column)) + + header_cells = ''.join( + f'{_escape(label)}' + for _key, label in normalized) + + body_rows = [] + for row in rows or []: + cells = ''.join( + f'' + f'{_escape(row.get(key) if isinstance(row, dict) else row)}' + for key, _label in normalized) + body_rows.append(f'{cells}') + + table = ( + '' + f'{header_cells}' + f'{"".join(body_rows) or ""}' + '
No data
' + ) + count_line = f'

{len(rows or [])} row(s).

' + return render_email(title, table + count_line, intro=intro) diff --git a/shopdb/utils/pagination.py b/shopdb/utils/pagination.py new file mode 100644 index 00000000..09bdf957 --- /dev/null +++ b/shopdb/utils/pagination.py @@ -0,0 +1,43 @@ +"""Pagination utilities.""" + +from flask import request, current_app +from typing import Tuple + + +def get_pagination_params(request_obj=None) -> Tuple[int, int]: + """ + Extract pagination parameters from request. + + Returns: + Tuple of (page, per_page) + """ + if request_obj is None: + request_obj = request + + default_size = current_app.config.get('DEFAULT_PAGE_SIZE', 20) + max_size = current_app.config.get('MAX_PAGE_SIZE', 100) + + try: + page = max(1, int(request_obj.args.get('page', 1))) + except (TypeError, ValueError): + page = 1 + + try: + per_page = int(request_obj.args.get('perpage', request_obj.args.get('per_page', default_size))) + per_page = max(1, min(per_page, max_size)) + except (TypeError, ValueError): + per_page = default_size + + return page, per_page + + +def paginate_query(query, page: int, per_page: int): + """ + Apply pagination to a SQLAlchemy query. + + Returns: + Tuple of (items, total) + """ + total = query.count() + items = query.offset((page - 1) * per_page).limit(per_page).all() + return items, total diff --git a/shopdb/utils/responses.py b/shopdb/utils/responses.py new file mode 100644 index 00000000..4b7d714c --- /dev/null +++ b/shopdb/utils/responses.py @@ -0,0 +1,175 @@ +"""Standardized API response helpers.""" + +from flask import jsonify, make_response +from typing import Any, Dict, List +from datetime import datetime, timezone +import uuid + + +class ErrorCodes: + """Standard error codes.""" + + VALIDATION_ERROR = 'VALIDATION_ERROR' + NOT_FOUND = 'NOT_FOUND' + UNAUTHORIZED = 'UNAUTHORIZED' + FORBIDDEN = 'FORBIDDEN' + CONFLICT = 'CONFLICT' + INTERNAL_ERROR = 'INTERNAL_ERROR' + BAD_REQUEST = 'BAD_REQUEST' + PLUGIN_ERROR = 'PLUGIN_ERROR' + + +def api_response( + data: Any = None, + message: str = None, + status: str = 'success', + meta: Dict = None, + http_code: int = 200 +): + """ + Create standardized API response. + + Response format: + { + "status": "success" | "error", + "data": {...} | [...], + "message": "Optional message", + "meta": { + "timestamp": "2025-01-12T...", + "request_id": "uuid" + } + } + """ + response = { + 'status': status, + 'meta': { + 'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z', + 'requestid': str(uuid.uuid4())[:8], + **(meta or {}) + } + } + + if data is not None: + response['data'] = data + + if message: + response['message'] = message + + return make_response(jsonify(response), http_code) + + +def success_response( + data: Any = None, + message: str = None, + meta: Dict = None, + http_code: int = 200 +): + """Success response helper.""" + return api_response( + data=data, + message=message, + meta=meta, + status='success', + http_code=http_code + ) + + +def error_response( + code: str, + message: str, + details: Dict = None, + http_code: int = 400 +): + """Error response helper. + + Response format: + { + "status": "error", + "data": { + "error": { + "code": "VALIDATION_ERROR", + "message": "Human-readable message", + "details": {...} + } + }, + "meta": { + "timestamp": "...", + "requestid": "..." + } + } + """ + error_data = { + 'code': code, + 'message': message + } + if details: + error_data['details'] = details + + return api_response( + data={'error': error_data}, + status='error', + http_code=http_code + ) + + +def api_error( + message: str, + code: str = ErrorCodes.BAD_REQUEST, + details: Dict = None, + http_code: int = 400 +): + """ + Simplified error response helper. + + Args: + message: Human-readable error message + code: Error code (default BAD_REQUEST) + details: Optional error details + http_code: HTTP status code (default 400) + """ + return error_response(code=code, message=message, details=details, http_code=http_code) + + +def paginated_response( + items: List, + page: int, + per_page: int, + total: int, + schema=None +): + """Paginated list response. + + Response format: + { + "status": "success", + "data": [...], + "meta": { + "pagination": { + "page": 1, + "perpage": 20, + "total": 150, + "totalpages": 8, + "hasnext": true, + "hasprev": false + } + } + } + """ + total_pages = (total + per_page - 1) // per_page if per_page > 0 else 0 + + if schema: + items = schema.dump(items, many=True) + + return api_response( + data=items, + meta={ + 'pagination': { + 'page': page, + 'perpage': per_page, + 'total': total, + 'totalpages': total_pages, + 'hasnext': page < total_pages, + 'hasprev': page > 1 + } + } + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..46816ddf --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d6555209 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,148 @@ +"""Pytest configuration and fixtures for shopdb-flask. + +Strategy: in-memory SQLite via StaticPool (configured in TestingConfig) +so the database is shared across the connection. Each test drops and +recreates the schema. Simple, totally isolated, fast enough for a small +schema. Switch to savepoint-per-test if test count grows past a few +hundred. +""" + +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from werkzeug.security import generate_password_hash + +# Force testing config before any shopdb import touches the env. +os.environ['FLASK_ENV'] = 'testing' + +from shopdb import create_app +from shopdb.extensions import db as _db + + +def _bootstrap_plugin_registry(): + # Fresh clones (CI) have no instance/plugins.json - the registry is + # gitignored dev state - so no plugins would load and every plugin-backed + # test fails. Seed a registry enabling all bundled plugins. An existing + # dev registry is left untouched. + repo = Path(__file__).resolve().parent.parent + state_file = repo / 'instance' / 'plugins.json' + if state_file.exists(): + return + stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + plugins = {} + for manifest_path in sorted((repo / 'plugins').glob('*/manifest.json')): + manifest = json.loads(manifest_path.read_text()) + plugins[manifest['name']] = { + 'name': manifest['name'], + 'version': manifest.get('version', '1.0.0'), + 'installed_at': stamp, + 'enabled': True, + 'migrations_applied': [], + 'config': {}, + } + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text(json.dumps({'plugins': plugins}, indent=2)) + + +_bootstrap_plugin_registry() + + +@pytest.fixture(scope='session') +def app(): + """Create the Flask application for the test session.""" + application = create_app('testing') + return application + + +@pytest.fixture(scope='function') +def db(app): + """Provide a fresh database per test. Drops and recreates schema each run.""" + with app.app_context(): + _db.create_all() + yield _db + _db.session.remove() + _db.drop_all() + + +@pytest.fixture +def client(app): + """Flask test client.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """Flask CLI test runner.""" + return app.test_cli_runner() + + +@pytest.fixture +def admin_user(db): + """Create an admin user for authenticated tests. + + The user has username 'testadmin' and password 'testpass'. + """ + from shopdb.core.models import User, Role + + role = Role(rolename='admin', description='Administrator') + db.session.add(role) + db.session.flush() + + user = User( + username='testadmin', + email='admin@test.local', + passwordhash=generate_password_hash('testpass'), + ) + user.roles.append(role) + db.session.add(user) + db.session.commit() + + return user + + +@pytest.fixture +def auth_headers(client, admin_user): + """Log in as admin_user and return Authorization headers.""" + response = client.post( + '/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}, + ) + assert response.status_code == 200, f'Login failed: {response.get_json()}' + payload = response.get_json() + token = payload['data']['access_token'] + return {'Authorization': f'Bearer {token}'} + + +@pytest.fixture +def member_user(db): + """Create an authenticated user with NO roles/permissions. + + Username 'testmember', password 'testpass'. Used to prove that + authentication alone does not grant write access (authorization gating). + """ + from shopdb.core.models import User + + user = User( + username='testmember', + email='member@test.local', + passwordhash=generate_password_hash('testpass'), + ) + db.session.add(user) + db.session.commit() + + return user + + +@pytest.fixture +def member_headers(client, member_user): + """Log in as member_user (no roles) and return Authorization headers.""" + response = client.post( + '/api/auth/login', + json={'username': 'testmember', 'password': 'testpass'}, + ) + assert response.status_code == 200, f'Login failed: {response.get_json()}' + token = response.get_json()['data']['access_token'] + return {'Authorization': f'Bearer {token}'} diff --git a/tests/test_api_namespace.py b/tests/test_api_namespace.py new file mode 100644 index 00000000..335410ac --- /dev/null +++ b/tests/test_api_namespace.py @@ -0,0 +1,270 @@ +"""Tests for the public shopdb.api namespace exposed to plugins. + +Pins audit_log, resolve_asset_position, and the BasePlugin +get_setting/set_setting helpers as part of the contract surface. +""" + +import pytest + +from shopdb.api import audit_log, resolve_asset_position +from shopdb.core.models import AuditLog, Setting + + +def test_audit_log_creates_row(db, client): + """audit_log writes an AuditLog row with the standard fields.""" + with client.application.test_request_context('/'): + entry = audit_log( + action='created', + entitytype='Computer', + entityid=42, + entityname='PC-1234', + changes={'before': {}, 'after': {'hostname': 'PC-1234'}}, + ) + + assert entry is not None + assert entry.action == 'created' + assert entry.entitytype == 'Computer' + assert entry.entityid == 42 + assert entry.entityname == 'PC-1234' + + saved = AuditLog.query.filter_by(entityid=42, entitytype='Computer').first() + assert saved is not None + + +def test_resolve_asset_position_returns_none_when_no_data(): + """An asset with no coords and no location returns None.""" + class FakeAsset: + mapx = None + mapy = None + location = None + + assert resolve_asset_position(FakeAsset()) is None + + +def test_resolve_asset_position_uses_self_when_set(): + """Asset-specific coords win over everything else.""" + class FakeLocation: + mapx = 10 + mapy = 20 + + class FakeAsset: + mapx = 100 + mapy = 200 + location = FakeLocation() + + result = resolve_asset_position(FakeAsset()) + assert result == {'mapx': 100, 'mapy': 200, 'positionsource': 'self'} + + +def test_resolve_asset_position_falls_back_to_location(): + """When asset has no coords, falls back to location coords.""" + class FakeLocation: + mapx = 50 + mapy = 75 + + class FakeAsset: + mapx = None + mapy = None + location = FakeLocation() + + result = resolve_asset_position(FakeAsset()) + assert result == {'mapx': 50, 'mapy': 75, 'positionsource': 'location'} + + +def test_resolve_asset_position_handles_asset_without_mapx_attr(): + """Assets that don't yet have mapx/mapy columns degrade gracefully.""" + class FakeLocation: + mapx = 1 + mapy = 2 + + class FakeAsset: + location = FakeLocation() + + result = resolve_asset_position(FakeAsset()) + assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'location'} + + +# --- Relationship-walk path (priority 2 in the chain) ---------------------- + +def _make_rel(rtype_name, neighbor, inheritsposition=True, isactive=True): + """Build a fake AssetRelationship-shaped object pointing at neighbor. + `neighbor` is wired as both source and target so the walk helper finds + it regardless of direction; tests pick which list to attach it to.""" + class FakeRelType: + relationshiptype = rtype_name + + class FakeRel: + pass + + r = FakeRel() + r.relationshiptype = FakeRelType() + r.inheritsposition = inheritsposition + r.isactive = isactive + r.targetasset = neighbor + r.sourceasset = neighbor + return r + + +def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, location=None): + class FakeAsset: + pass + + a = FakeAsset() + a.assetid = assetid + a.mapx = mapx + a.mapy = mapy + a.outgoing_relationships = outgoing or [] + a.incoming_relationships = incoming or [] + a.location = location + return a + + +def test_resolve_asset_position_walks_partof_edge(): + """Priority 2: inheritsposition=true partof edge resolves from neighbor.""" + parent = _make_asset(assetid=2, mapx=300, mapy=400) + rel = _make_rel('partof', parent) + child = _make_asset(assetid=1, outgoing=[rel]) + + result = resolve_asset_position(child) + assert result == {'mapx': 300, 'mapy': 400, 'positionsource': 'related'} + + +def test_resolve_asset_position_walks_controls_after_partof(): + """Priority 2 ordering: partof beats controls when both have coords.""" + partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12) + controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99) + rel_partof = _make_rel('partof', partof_neighbor) + rel_controls = _make_rel('controls', controls_neighbor) + asset = _make_asset(assetid=1, outgoing=[rel_controls, rel_partof]) + + result = resolve_asset_position(asset) + assert result == {'mapx': 11, 'mapy': 12, 'positionsource': 'related'} + + +def test_resolve_asset_position_skips_non_inheritable_type(): + """connectedto edges are never walked even if inheritsposition is true.""" + neighbor = _make_asset(assetid=2, mapx=5, mapy=6) + rel = _make_rel('connectedto', neighbor, inheritsposition=True) + asset = _make_asset(assetid=1, outgoing=[rel]) + + assert resolve_asset_position(asset) is None + + +def test_resolve_asset_position_skips_when_inheritsposition_false(): + """An edge with inheritsposition=false is not walked.""" + neighbor = _make_asset(assetid=2, mapx=5, mapy=6) + rel = _make_rel('partof', neighbor, inheritsposition=False) + asset = _make_asset(assetid=1, outgoing=[rel]) + + assert resolve_asset_position(asset) is None + + +def test_resolve_asset_position_walks_recursively(): + """The walk recurses: child -> middle -> root, where only root has coords.""" + root = _make_asset(assetid=3, mapx=1, mapy=2) + middle = _make_asset(assetid=2, outgoing=[_make_rel('partof', root)]) + child = _make_asset(assetid=1, outgoing=[_make_rel('partof', middle)]) + + result = resolve_asset_position(child) + assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'related'} + + +def test_resolve_asset_position_breaks_cycles(): + """A cycle A<->B with no coords anywhere returns None without recursing + forever.""" + a = _make_asset(assetid=1) + b = _make_asset(assetid=2) + a.outgoing_relationships = [_make_rel('partof', b)] + b.outgoing_relationships = [_make_rel('partof', a)] + + assert resolve_asset_position(a) is None + + +def test_resolve_asset_position_depth_cap_is_three(): + """Past depth 3 the walk gives up. Build a chain of 5 nodes where only + the last has coords; expect None.""" + coords_node = _make_asset(assetid=5, mapx=99, mapy=99) + n4 = _make_asset(assetid=4, outgoing=[_make_rel('partof', coords_node)]) + n3 = _make_asset(assetid=3, outgoing=[_make_rel('partof', n4)]) + n2 = _make_asset(assetid=2, outgoing=[_make_rel('partof', n3)]) + root = _make_asset(assetid=1, outgoing=[_make_rel('partof', n2)]) + + assert resolve_asset_position(root) is None + + +def test_resolve_asset_position_self_beats_related(): + """Priority 1 beats priority 2: asset's own coords win even when a + related neighbor would also resolve.""" + neighbor = _make_asset(assetid=2, mapx=99, mapy=99) + rel = _make_rel('partof', neighbor) + asset = _make_asset(assetid=1, mapx=1, mapy=2, outgoing=[rel]) + + result = resolve_asset_position(asset) + assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'self'} + + +def test_resolve_asset_position_related_beats_location(): + """Priority 2 beats priority 3: a related neighbor's coords win over + the asset's location coords.""" + class FakeLocation: + mapx = 500 + mapy = 600 + + neighbor = _make_asset(assetid=2, mapx=10, mapy=20) + rel = _make_rel('partof', neighbor) + asset = _make_asset(assetid=1, outgoing=[rel], location=FakeLocation()) + + result = resolve_asset_position(asset) + assert result == {'mapx': 10, 'mapy': 20, 'positionsource': 'related'} + + +def test_resolve_asset_position_inactive_edge_skipped(): + """Soft-deleted (isactive=false) relationships are not walked.""" + neighbor = _make_asset(assetid=2, mapx=5, mapy=6) + rel = _make_rel('partof', neighbor, isactive=False) + asset = _make_asset(assetid=1, outgoing=[rel]) + + assert resolve_asset_position(asset) is None + + +def test_plugin_get_setting_returns_default_when_unset(db, app): + """A plugin reading an unset setting gets the default.""" + from shopdb.plugins import plugin_manager + with app.app_context(): + printers = plugin_manager.get_plugin('printers') + if printers is None: + pytest.skip('printers plugin not loaded') + assert printers.get_setting('nonexistentkey', default='fallback') == 'fallback' + + +def test_plugin_set_and_get_setting_roundtrip(db, app): + """A plugin can set and read its own setting; key is namespaced.""" + from shopdb.plugins import plugin_manager + with app.app_context(): + printers = plugin_manager.get_plugin('printers') + if printers is None: + pytest.skip('printers plugin not loaded') + + printers.set_setting('zabbix_url', 'http://zabbix.example.com') + + assert printers.get_setting('zabbix_url') == 'http://zabbix.example.com' + + raw = Setting.query.filter_by(key='plugin.printers.zabbix_url').first() + assert raw is not None + assert raw.value == 'http://zabbix.example.com' + + +def test_plugin_setting_is_namespaced_per_plugin(db, app): + """Two plugins using the same key do not collide.""" + from shopdb.plugins import plugin_manager + with app.app_context(): + printers = plugin_manager.get_plugin('printers') + computers = plugin_manager.get_plugin('computers') + if printers is None or computers is None: + pytest.skip('required plugins not loaded') + + printers.set_setting('shared_key', 'printers_value') + computers.set_setting('shared_key', 'computers_value') + + assert printers.get_setting('shared_key') == 'printers_value' + assert computers.get_setting('shared_key') == 'computers_value' diff --git a/tests/test_core/__init__.py b/tests/test_core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_core/test_apitokens.py b/tests/test_core/test_apitokens.py new file mode 100644 index 00000000..1b0bdaef --- /dev/null +++ b/tests/test_core/test_apitokens.py @@ -0,0 +1,419 @@ +"""Personal API token tests. + +Covers: create returns the secret once and stores only a hash; a PAT +authenticates a permission-gated write as its owner; a PAT is rejected when its +owner lacks the permission; expired and revoked tokens are rejected; lastusedat +updates on use; a non-owner member cannot revoke someone else's token; an admin +lists everyone's tokens with ?all=true; and import mode works over a PAT for an +admin. +""" + +from datetime import datetime, timedelta, timezone + +from werkzeug.security import generate_password_hash + +from shopdb.core.models import ApiToken, Application, Vendor +from shopdb.extensions import db as _db + + +def _naive_utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _create_token(client, headers, name='test token', expiresat=None, scopes=None): + body = {'name': name} + if expiresat is not None: + body['expiresat'] = expiresat + if scopes is not None: + body['scopes'] = scopes + response = client.post('/api/apitokens', json=body, headers=headers) + return response + + +def _pat_headers(secret): + return {'Authorization': f'Bearer {secret}'} + + +def _user_with_perms(client, db, username, permnames): + """Create a non-admin user holding permnames, return (user, login headers). + + Seeds the catalog so the named Permission rows exist, links them to a fresh + role, and logs the user in. Used to prove the apitokens.create gate and the + owner scope ceiling for non-admin owners. + """ + from shopdb.core.models import User, Role, Permission + Permission.seed() + role = Role(rolename=username + 'role') + db.session.add(role) + db.session.flush() + role.permissions = Permission.query.filter( + Permission.name.in_(permnames)).all() + user = User(username=username, email=username + '@test.local', + passwordhash=generate_password_hash('testpass')) + user.roles.append(role) + db.session.add(user) + db.session.commit() + login = client.post('/api/auth/login', + json={'username': username, 'password': 'testpass'}) + headers = {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"} + return user, headers + + +def test_create_returns_secret_once_and_stores_hash(client, db, auth_headers): + response = _create_token(client, auth_headers, name='import runner') + assert response.status_code == 201 + data = response.get_json()['data'] + + secret = data['secret'] + assert secret.startswith('shopdb_pat_') + assert 'warning' in data + # The stored row must not carry the raw secret; only its hash. + token = ApiToken.query.filter_by(tokenid=data['tokenid']).first() + assert token is not None + assert token.tokenhash == ApiToken.hash_secret(secret) + assert secret not in (token.tokenhash, token.tokenprefix or '') + assert token.tokenprefix and token.tokenprefix in secret + + +def test_pat_authenticates_permission_write_as_owner(client, db, admin_user, + auth_headers): + """A PAT owned by an admin can create a vendor (admin-gated write).""" + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + + response = client.post('/api/vendors', json={'vendor': 'PAT Vendor'}, + headers=_pat_headers(secret)) + assert response.status_code == 201 + assert Vendor.query.filter_by(vendor='PAT Vendor').first() is not None + + +def test_pat_403_when_owner_lacks_permission(client, db): + """A PAT owned by a non-admin (who can mint tokens but is not admin) is + forbidden from an admin-role write.""" + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + secret = _create_token(client, headers).get_json()['data']['secret'] + + response = client.post('/api/vendors', json={'vendor': 'Nope'}, + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert Vendor.query.filter_by(vendor='Nope').first() is None + + +def test_expired_token_rejected(client, db, admin_user, auth_headers): + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + token.expiresat = _naive_utcnow() - timedelta(days=1) + _db.session.commit() + + response = client.post('/api/vendors', json={'vendor': 'Expired'}, + headers=_pat_headers(secret)) + assert response.status_code == 401 + assert Vendor.query.filter_by(vendor='Expired').first() is None + + +def test_revoked_token_rejected(client, db, admin_user, auth_headers): + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + tokenid = token.tokenid + + revoke = client.delete(f'/api/apitokens/{tokenid}', headers=auth_headers) + assert revoke.status_code == 200 + + response = client.post('/api/vendors', json={'vendor': 'Revoked'}, + headers=_pat_headers(secret)) + assert response.status_code == 401 + assert Vendor.query.filter_by(vendor='Revoked').first() is None + + +def test_lastusedat_updates_on_use(client, db, admin_user, auth_headers): + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.lastusedat is None + + client.get('/api/apitokens', headers=_pat_headers(secret)) + _db.session.expire_all() + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.lastusedat is not None + + +def test_member_cannot_revoke_other_users_token(client, db, admin_user, + auth_headers): + """A token-capable non-admin cannot revoke a token owned by someone else.""" + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + tokenid = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first().tokenid + + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + response = client.delete(f'/api/apitokens/{tokenid}', headers=headers) + assert response.status_code == 403 + # Still active. + assert _db.session.get(ApiToken, tokenid).isactive is True + + +def test_roleless_member_cannot_create_token(client, db, member_user, + member_headers): + """Creating a token now requires apitokens.create; a role-less member is + denied with 403.""" + response = _create_token(client, member_headers, name='nope') + assert response.status_code == 403 + + +def test_member_with_permission_can_manage_own_token(client, db): + """A non-admin granted apitokens.create manages their OWN tokens.""" + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + create = _create_token(client, headers, name='mine') + assert create.status_code == 201 + tokenid = create.get_json()['data']['tokenid'] + + revoke = client.delete(f'/api/apitokens/{tokenid}', headers=headers) + assert revoke.status_code == 200 + assert _db.session.get(ApiToken, tokenid).isactive is False + + +def test_admin_all_true_lists_everyone(client, db, admin_user, auth_headers): + _create_token(client, auth_headers, name='admin token') + member, member_headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + _create_token(client, member_headers, name='member token') + + # Own-only (default) for admin: just the admin's token. + own = client.get('/api/apitokens', headers=auth_headers).get_json()['data'] + assert all(t['userid'] == admin_user.userid for t in own) + + # all=true: both users' tokens, with owner usernames. + everyone = client.get('/api/apitokens?all=true', + headers=auth_headers).get_json()['data'] + userids = {t['userid'] for t in everyone} + assert admin_user.userid in userids and member.userid in userids + assert any(t.get('username') for t in everyone) + + +def test_member_all_true_ignored(client, db, admin_user, auth_headers): + """A non-admin passing ?all=true still only sees their own tokens.""" + _create_token(client, auth_headers, name='admin token') + member, member_headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + _create_token(client, member_headers, name='member token') + + result = client.get('/api/apitokens?all=true', + headers=member_headers).get_json()['data'] + assert all(t['userid'] == member.userid for t in result) + + +def test_import_mode_works_over_pat(client, db, admin_user, auth_headers): + """An admin PAT plus X-Import-Mode backdates createddate on a write.""" + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + + headers = _pat_headers(secret) + headers['X-Import-Mode'] = 'true' + response = client.post( + '/api/vendors', + json={'vendor': 'Legacy Vendor', 'createddate': '2019-01-02 03:04:05'}, + headers=headers) + assert response.status_code == 201 + + vendor = Vendor.query.filter_by(vendor='Legacy Vendor').first() + assert vendor is not None + assert vendor.createddate == datetime(2019, 1, 2, 3, 4, 5) + + +# --- Scoped tokens ------------------------------------------------------- + +def test_scoped_token_passes_listed_permission(client, db, admin_user, + auth_headers): + """A token scoped to applications.create can create an application.""" + secret = _create_token(client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + + response = client.post('/api/applications', json={'appname': 'Scoped App'}, + headers=_pat_headers(secret)) + assert response.status_code == 201 + assert Application.query.filter_by(appname='Scoped App').first() is not None + + +def test_scoped_token_403_on_unlisted_permission(client, db, admin_user, + auth_headers): + """A token scoped to only applications.create cannot delete (needs + applications.delete, which is not in scope).""" + app = Application(appname='Target App') + _db.session.add(app) + _db.session.commit() + appid = app.appid + + secret = _create_token(client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + response = client.delete(f'/api/applications/{appid}', + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert _db.session.get(Application, appid) is not None + + +def test_scoped_admin_token_does_not_bypass(client, db, admin_user, auth_headers): + """An admin owner's scoped token is genuinely limited: the admin permission + bypass is suspended, so an unlisted action is 403 even for an admin.""" + secret = _create_token(client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + + # settings.edit is not scoped -> 403 despite the owner being admin. + response = client.put('/api/settings/site_name', + json={'value': 'Hacked'}, headers=_pat_headers(secret)) + assert response.status_code == 403 + + +def test_scoped_token_denied_on_require_role_route(client, db, admin_user, + auth_headers): + """Scoped tokens cannot pass a role gate. Vendor writes are require_role + admin, so even an admin-owned scoped token is denied.""" + secret = _create_token(client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + + response = client.post('/api/vendors', json={'vendor': 'RoleGated'}, + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert Vendor.query.filter_by(vendor='RoleGated').first() is None + + +def test_unscoped_token_unchanged(client, db, admin_user, auth_headers): + """Regression: an unscoped admin token still acts with full authority, + including role-gated writes.""" + secret = _create_token(client, auth_headers).get_json()['data']['secret'] + + response = client.post('/api/vendors', json={'vendor': 'Unscoped Vendor'}, + headers=_pat_headers(secret)) + assert response.status_code == 201 + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.scopelist is None + + +def test_unknown_scope_name_rejected_at_create(client, db, admin_user, + auth_headers): + """An unknown permission name in scopes is rejected 400, listing it.""" + response = _create_token(client, auth_headers, scopes=['not.a.permission']) + assert response.status_code == 400 + assert 'not.a.permission' in response.get_json()['data']['error']['message'] + + +def test_scope_ceiling_rejects_permission_owner_lacks(client, db): + """The scope ceiling: a non-admin cannot mint a token scoped to a + permission they do not hold.""" + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + response = _create_token(client, headers, scopes=['applications.create']) + assert response.status_code == 400 + assert 'applications.create' in response.get_json()['data']['error']['message'] + + +def test_non_admin_scoped_token_capped_at_own_permissions(client, db): + """A non-admin holding applications.create + apitokens.create may mint a + token scoped to applications.create and use it.""" + _user, headers = _user_with_perms( + client, db, 'tokenmaker', ['apitokens.create', 'applications.create']) + secret = _create_token(client, headers, + scopes=['applications.create']).get_json()['data']['secret'] + + response = client.post('/api/applications', json={'appname': 'Capped App'}, + headers=_pat_headers(secret)) + assert response.status_code == 201 + + +def test_scoped_token_intersects_at_use_time(client, db): + """Defense in depth: if the owner loses a scoped permission after the token + is minted, the token can no longer use it (use-time intersection).""" + from shopdb.core.models import Role + _user, headers = _user_with_perms( + client, db, 'tokenmaker', ['apitokens.create', 'applications.create']) + secret = _create_token(client, headers, + scopes=['applications.create']).get_json()['data']['secret'] + + # Strip applications.create from the owner's role. + role = Role.query.filter_by(rolename='tokenmakerrole').first() + role.permissions = [p for p in role.permissions + if p.name != 'applications.create'] + _db.session.commit() + + response = client.post('/api/applications', json={'appname': 'Gone'}, + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert Application.query.filter_by(appname='Gone').first() is None + + +def test_import_mode_denied_over_scoped_admin_token(client, db, admin_user, + auth_headers): + """A scoped admin token does NOT get import mode (an admin-role capability): + the createddate in the body is ignored and 'now' is stamped instead.""" + secret = _create_token( + client, auth_headers, + scopes=['applications.create']).get_json()['data']['secret'] + + headers = _pat_headers(secret) + headers['X-Import-Mode'] = 'true' + before = _naive_utcnow() + response = client.post( + '/api/applications', + json={'appname': 'No Backdate', 'createddate': '2019-01-02 03:04:05'}, + headers=headers) + assert response.status_code == 201 + + app = Application.query.filter_by(appname='No Backdate').first() + assert app is not None + # Import mode was denied, so createddate is ~now, not the 2019 value. + assert app.createddate >= before + + +def test_scopes_update_round_trip(client, db, admin_user, auth_headers): + """Scopes can be set, changed, and cleared via PUT, and surface in GET.""" + create = _create_token(client, auth_headers, scopes=['applications.create']) + tokenid = create.get_json()['data']['tokenid'] + + got = client.get('/api/apitokens', headers=auth_headers).get_json()['data'] + row = next(t for t in got if t['tokenid'] == tokenid) + assert row['scopes'] == ['applications.create'] + + # Change scopes. + updated = client.put(f'/api/apitokens/{tokenid}', + json={'scopes': ['applications.edit']}, + headers=auth_headers) + assert updated.status_code == 200 + assert updated.get_json()['data']['scopes'] == ['applications.edit'] + + # Clear scopes (back to unscoped/full). + cleared = client.put(f'/api/apitokens/{tokenid}', json={'scopes': None}, + headers=auth_headers) + assert cleared.status_code == 200 + assert cleared.get_json()['data']['scopes'] is None + + +def test_collector_scoped_token_denied_on_normal_endpoints(client, db, + admin_user, + auth_headers): + """A collector-scoped token (collector.ingest only) is contained: the shim + still swaps it into a JWT, but collector.ingest gates no normal route, so it + is denied on a permission-gated create just like any other unlisted scope.""" + secret = _create_token( + client, auth_headers, + scopes=['collector.ingest']).get_json()['data']['secret'] + + response = client.post('/api/applications', json={'appname': 'Nope'}, + headers=_pat_headers(secret)) + assert response.status_code == 403 + assert Application.query.filter_by(appname='Nope').first() is None + + +def test_update_scopes_ceiling_enforced(client, db): + """PUT scopes is also capped at the owner's permissions.""" + _user, headers = _user_with_perms(client, db, 'tokenmaker', + ['apitokens.create']) + tokenid = _create_token(client, headers).get_json()['data']['tokenid'] + + response = client.put(f'/api/apitokens/{tokenid}', + json={'scopes': ['applications.delete']}, + headers=headers) + assert response.status_code == 400 + assert 'applications.delete' in response.get_json()['data']['error']['message'] diff --git a/tests/test_core/test_auth_ratelimit.py b/tests/test_core/test_auth_ratelimit.py new file mode 100644 index 00000000..84ab1e71 --- /dev/null +++ b/tests/test_core/test_auth_ratelimit.py @@ -0,0 +1,70 @@ +"""Tests for IP-based login rate limiting. + +The limiter is disabled in TestingConfig so login-heavy fixtures do not trip +it. These tests flip it on via app.config overrides and clear the shared cache +so counters do not bleed between cases. +""" + +import pytest + +from shopdb.extensions import cache + + +@pytest.fixture +def ratelimit_on(app): + """Enable a tight login rate limit for the duration of one test.""" + saved = { + 'enabled': app.config.get('AUTH_RATELIMIT_ENABLED'), + 'max': app.config.get('AUTH_RATELIMIT_MAX'), + 'window': app.config.get('AUTH_RATELIMIT_WINDOW_SECONDS'), + } + app.config['AUTH_RATELIMIT_ENABLED'] = True + app.config['AUTH_RATELIMIT_MAX'] = 3 + app.config['AUTH_RATELIMIT_WINDOW_SECONDS'] = 300 + with app.app_context(): + cache.clear() + yield + with app.app_context(): + cache.clear() + app.config['AUTH_RATELIMIT_ENABLED'] = saved['enabled'] + app.config['AUTH_RATELIMIT_MAX'] = saved['max'] + app.config['AUTH_RATELIMIT_WINDOW_SECONDS'] = saved['window'] + + +def _bad_login(client, ip): + """Attempt a login for a non-existent user from a given source IP.""" + return client.post( + '/api/auth/login', + json={'username': 'ghost', 'password': 'wrong'}, + headers={'X-Forwarded-For': ip}, + ) + + +def test_login_flood_from_one_ip_is_rate_limited(client, db, ratelimit_on): + """After the per-window budget is spent, further attempts get 429.""" + ip = '203.0.113.10' + for _ in range(3): + assert _bad_login(client, ip).status_code == 401 + # Fourth attempt in the same window is over budget. + blocked = _bad_login(client, ip) + assert blocked.status_code == 429 + assert blocked.get_json()['data']['error']['code'] == 'RATE_LIMITED' + + +def test_different_ip_not_penalized(client, db, ratelimit_on): + """One flooding IP does not lock out a different caller's IP.""" + flooder = '203.0.113.20' + for _ in range(4): + _bad_login(client, flooder) + assert _bad_login(client, flooder).status_code == 429 + + # A separate IP still gets the normal 401, not a 429. + other = _bad_login(client, '203.0.113.21') + assert other.status_code == 401 + + +def test_limiter_disabled_by_default_in_testing(client, db): + """With TestingConfig defaults the limiter is off; repeated logins stay 401.""" + for _ in range(10): + resp = _bad_login(client, '203.0.113.30') + assert resp.status_code == 401 diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py new file mode 100644 index 00000000..75d4cd80 --- /dev/null +++ b/tests/test_core/test_authz.py @@ -0,0 +1,342 @@ +"""Authorization gating and login-lockout tests. + +Covers the security fixes: + - write routes require a permission/role, not just authentication + - the admin role bypasses permission checks + - repeated bad logins lock the account + +The parametrized sweep at the bottom is the standing CI guard: it walks the +whole URL map and proves every mutating route (POST/PUT/PATCH/DELETE) either +rejects a role-less member with 403 or is on the deliberate exemption list. +A future unguarded mutation therefore fails CI the moment it is added. +""" + +import re + +import pytest + +from shopdb import create_app + + +# HTTP verbs that change state. GETs are intentionally left alone (kiosk pages +# rely on optional-jwt reads). +MUTATING_METHODS = {'POST', 'PUT', 'PATCH', 'DELETE'} + +# Blueprints exempt by design (see task/ADR-006): +# auth - login/refresh/logout are the auth surface itself +# collector - agent ingest, authenticated by per-plugin API key (ADR-006) +# setup - first-run wizard, self-gated by the needs-admin state +EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'} + +# Individual endpoints exempt by design. +# knowledgebase.track_click - kiosk click-through counter. Optional-jwt +# telemetry: increments a click count on an already-active article and +# returns its link URL. Takes no caller-supplied data; gating it would +# break the unauthenticated kiosk redirect it exists to serve. +# users.update_user - admin-or-self (inline check, not a flat deny): a +# role-less member MAY edit their own record, so it does not fit the +# 403-for-every-member contract this sweep asserts. The other-user 403 is +# covered by test_member_cannot_update_other_user below. +# The apitokens create/update/revoke endpoints are NOT exempt: they now require +# the apitokens.create permission, so a role-less member gets the 403 this sweep +# asserts (ownership is still enforced inside the handler for non-admins). +# geenforce.post_report - GE-Enforce client ingest, authenticated by a +# geenforce.report managed service token (X-API-Key/Bearer), not JWT. Same +# shape as the exempt collector blueprint; the geenforce admin endpoints in +# the same blueprint are JWT+permission gated and ARE swept. +EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', + 'geenforce.post_report'} + + +@pytest.fixture(autouse=True) +def _selfhosted_directory_modes(db): + # The usb/employees plugins default to EXTERNAL directory mode when no + # setting row exists, so exercising their handlers reaches for a MySQL + # server that exists on the dev box but not in CI. Pin selfhosted mode + # so the sweep is deterministic everywhere. + from shopdb.core.models import Setting + db.session.add(Setting(key='usb_directory_mode', value='selfhosted', + valuetype='string', category='site')) + db.session.add(Setting(key='employee_directory_mode', value='selfhosted', + valuetype='string', category='site')) + db.session.commit() + + +def _fill_url(rule): + """Turn a Werkzeug rule into a concrete path (int/float params -> 1, else x).""" + def repl(match): + converter = match.group(1) or '' + return '1' if converter.startswith(('int', 'float')) else 'x' + # matches , , , ... + return re.sub(r'<(?:([^:>]+):)?[^>]+>', repl, rule) + + +def _mutating_routes(): + """Every non-exempt mutating (endpoint, method, url) in the URL map. + + Enumerated from a throwaway app: route strings are stable across app + instances, so the requests below run against the fixture client's app. + """ + app = create_app('testing') + routes = [] + for rule in app.url_map.iter_rules(): + methods = (rule.methods or set()) & MUTATING_METHODS + if not methods: + continue + blueprint = rule.endpoint.split('.')[0] + if blueprint in EXEMPT_BLUEPRINTS or rule.endpoint in EXEMPT_ENDPOINTS: + continue + for method in sorted(methods): + routes.append((rule.endpoint, method, _fill_url(rule.rule))) + return routes + + +# Collected once at import so each route is its own parametrized case (a +# newly-unguarded mutation shows up as a single named CI failure). +MUTATING_ROUTES = _mutating_routes() +ROUTE_IDS = [f'{ep}[{method}]' for ep, method, _ in MUTATING_ROUTES] + + +def test_member_cannot_write_settings(client, db, member_headers): + """An authenticated user with no roles is forbidden from editing settings.""" + from shopdb.core.models import Setting + + setting = Setting(key='zabbix_enabled', value='false', valuetype='boolean', + category='integrations') + db.session.add(setting) + db.session.commit() + + response = client.put('/api/settings/zabbix_enabled', + json={'value': True}, headers=member_headers) + assert response.status_code == 403 + assert response.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_admin_can_write_settings(client, db, auth_headers): + """The admin role bypasses the permission check and may edit settings.""" + from shopdb.core.models import Setting + + setting = Setting(key='zabbix_enabled', value='false', valuetype='boolean', + category='integrations') + db.session.add(setting) + db.session.commit() + + response = client.put('/api/settings/zabbix_enabled', + json={'value': True}, headers=auth_headers) + assert response.status_code == 200 + + +def test_member_cannot_delete_asset(client, db, member_headers): + """A role-less user cannot delete assets (was previously allowed).""" + response = client.delete('/api/assets/1', headers=member_headers) + # Forbidden by authz, not a 404 - the gate runs before the lookup. + assert response.status_code == 403 + + +def test_unauthenticated_write_is_rejected(client, db): + """No token at all still cannot reach a write route.""" + response = client.delete('/api/assets/1') + assert response.status_code in (401, 422) # missing/!invalid JWT + + +def test_member_cannot_create_business_unit(client, db, member_headers): + """Reference-data writes are admin-only.""" + response = client.post('/api/businessunits', + json={'businessunit': 'Test BU'}, headers=member_headers) + assert response.status_code == 403 + + +def test_member_cannot_list_users(client, db, member_headers): + """User listing is admin-only (converted from inline check to decorator).""" + response = client.get('/api/users', headers=member_headers) + assert response.status_code == 403 + assert response.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_member_cannot_create_user(client, db, member_headers): + """User creation is admin-only.""" + response = client.post('/api/users', + json={'username': 'x', 'email': 'x@test.local', + 'password': 'secret'}, + headers=member_headers) + assert response.status_code == 403 + + +def test_member_cannot_delete_role(client, db, member_headers): + """Role deletion is admin-only.""" + response = client.delete('/api/users/roles/1', headers=member_headers) + assert response.status_code == 403 + + +def test_admin_can_list_users(client, db, auth_headers): + """The admin role passes the require_role gate on user listing.""" + response = client.get('/api/users', headers=auth_headers) + assert response.status_code == 200 + + +def test_member_can_read_own_user(client, db, member_user, member_headers): + """Admin-or-self: a role-less user may read their OWN record.""" + response = client.get(f'/api/users/{member_user.userid}', + headers=member_headers) + assert response.status_code == 200 + assert response.get_json()['data']['username'] == 'testmember' + + +def test_member_cannot_read_other_user(client, db, member_user, member_headers): + """Admin-or-self: a role-less user may not read a DIFFERENT record.""" + response = client.get(f'/api/users/{member_user.userid + 999}', + headers=member_headers) + assert response.status_code == 403 + + +def test_account_locks_after_repeated_bad_logins(client, db, admin_user): + """Five bad passwords lock the account; a correct password is then refused.""" + for _ in range(5): + bad = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'wrong'}) + assert bad.status_code == 401 + + # Account is now locked - even the correct password is refused with 403. + locked = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}) + assert locked.status_code == 403 + assert 'locked' in locked.get_json()['data']['error']['message'].lower() + + +def test_lockout_auto_unlocks_after_expiry(client, db, admin_user): + """Once lockeduntil is in the past the account unlocks itself: a correct + password logs in and clears the lock state.""" + from datetime import datetime, timezone, timedelta + # Simulate an expired lock (naive-UTC to match the column comparisons). + admin_user.lockeduntil = (datetime.now(timezone.utc).replace(tzinfo=None) + - timedelta(minutes=1)) + admin_user.failedlogins = 5 + db.session.commit() + + ok = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}) + assert ok.status_code == 200, ok.get_json() + + db.session.refresh(admin_user) + assert admin_user.lockeduntil is None + assert admin_user.failedlogins == 0 + + +def test_successful_login_resets_failed_counter(client, db, admin_user): + """A good login before the threshold clears the failure count.""" + for _ in range(3): + client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'wrong'}) + + ok = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}) + assert ok.status_code == 200 + + from shopdb.core.models import User + user = User.query.filter_by(username='testadmin').first() + assert user.failedlogins == 0 + assert user.lockeduntil is None + + +# --------------------------------------------------------------------------- +# Standing CI guard: sweep every mutating route. +# --------------------------------------------------------------------------- + +def test_member_cannot_update_other_user(client, db, member_user, member_headers): + """Admin-or-self write gate: a role-less user cannot edit a DIFFERENT user.""" + response = client.put(f'/api/users/{member_user.userid + 999}', + json={'firstname': 'x'}, headers=member_headers) + assert response.status_code == 403 + + +def test_route_map_has_mutating_routes(): + """Sanity: the sweep actually found routes (catches a broken collector).""" + assert len(MUTATING_ROUTES) > 50, 'route enumeration looks empty/broken' + + +@pytest.mark.parametrize('endpoint,method,url', MUTATING_ROUTES, ids=ROUTE_IDS) +def test_mutation_rejects_roleless_member(client, db, member_headers, + endpoint, method, url): + """Every non-exempt mutating route must 403 a logged-in, role-less user. + + A 200/201/400/404/409 here means the route ran its handler for an + unprivileged caller - i.e. it is not permission-gated. That is the exact + regression this guard exists to catch. + """ + response = client.open(url, method=method, headers=member_headers, json={}) + assert response.status_code == 403, ( + f'{method} {endpoint} ({url}) returned {response.status_code}, ' + f'expected 403 - route is not permission-gated. If it is intended to ' + f'be public/kiosk, add it to EXEMPT_ENDPOINTS with a reason.' + ) + assert response.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +@pytest.mark.parametrize('endpoint,method,url', MUTATING_ROUTES, ids=ROUTE_IDS) +def test_mutation_admin_passes_authz(client, db, auth_headers, + endpoint, method, url): + """Admin must clear the gate on every mutating route (never 401/403). + + Downstream the handler may 400/404/409/422 on the empty body - that is + validation, not authorization, and is fine. We only assert the authz gate + let the admin through. + """ + response = client.open(url, method=method, headers=auth_headers, json={}) + assert response.status_code not in (401, 403), ( + f'{method} {endpoint} ({url}) returned {response.status_code} for ' + f'admin - the admin role should bypass every permission check.' + ) + + +# --- Settings exposure allowlist (unauthenticated recon guard) --------------- + +def _seed_settings(db): + from shopdb.core.models import Setting + db.session.add_all([ + Setting(key='facility_name', value='WJ', valuetype='string', category='site'), + Setting(key='brand_primary_color', value='#123', valuetype='string', category='branding'), + Setting(key='smtp_host', value='mail.internal', valuetype='string', category='communication'), + Setting(key='employee_db_host', value='hr.internal', valuetype='string', category='integrations'), + Setting(key='zabbix_token', value='supersecret', valuetype='string', category='integrations'), + ]) + db.session.commit() + + +def test_unauthenticated_settings_list_is_allowlisted(client, db): + """No token: only the public allowlist (branding + bootstrap keys) is + visible; internal infra config is not enumerable.""" + _seed_settings(db) + keys = {s['key'] for s in client.get('/api/settings').get_json()['data']} + assert 'facility_name' in keys and 'brand_primary_color' in keys + assert 'smtp_host' not in keys + assert 'employee_db_host' not in keys + assert 'zabbix_token' not in keys + + +def test_authenticated_settings_list_full_but_masked(client, db, member_headers): + """Any authed principal sees the full table; secret values stay masked.""" + _seed_settings(db) + rows = {s['key']: s['value'] + for s in client.get('/api/settings', headers=member_headers).get_json()['data']} + assert 'smtp_host' in rows and 'employee_db_host' in rows + assert rows['zabbix_token'] == '********' # secret masked even when visible + + +def test_unauthenticated_private_setting_get_is_404(client, db): + """A single private key is not confirmable without auth; a public one is.""" + _seed_settings(db) + assert client.get('/api/settings/smtp_host').status_code == 404 + assert client.get('/api/settings/facility_name').status_code == 200 + + +# --- audit.view gating ------------------------------------------------------- + +def test_member_cannot_read_auditlogs(client, db, member_headers): + """Audit-read routes now require audit.view; a role-less member is 403.""" + for url in ('/api/auditlogs', '/api/auditlogs/entity/Setting/1', '/api/auditlogs/stats'): + assert client.get(url, headers=member_headers).status_code == 403, url + + +def test_admin_can_read_auditlogs(client, db, auth_headers): + """Admin holds audit.view and reads the audit trail.""" + assert client.get('/api/auditlogs', headers=auth_headers).status_code == 200 diff --git a/tests/test_core/test_collector_contract.py b/tests/test_core/test_collector_contract.py new file mode 100644 index 00000000..175b0db2 --- /dev/null +++ b/tests/test_core/test_collector_contract.py @@ -0,0 +1,434 @@ +"""Tests for the generic plugin collector contract (ADR-006). + +Covers the auto-dispatched /api/collector/ endpoint, per-plugin API +key auth, idempotent upsert by identity field, and the JWT-protected +/api/collector/_schemas listing. +""" + +import pytest + +KEY = 'testcollectorkey' + + +@pytest.fixture +def collector_key(app): + """Set the shared collector API key for the test.""" + old = app.config.get('COLLECTOR_API_KEY') + app.config['COLLECTOR_API_KEY'] = KEY + yield KEY + app.config['COLLECTOR_API_KEY'] = old + + +@pytest.fixture +def computer_assettype(db): + """Seed the computer asset type needed for create-on-ingest.""" + from shopdb.core.models import AssetType + + at = AssetType(assettype='computer', pluginname='computers', + tablename='computers', description='PCs') + db.session.add(at) + db.session.commit() + return at + + +def test_schemas_requires_jwt(client, db): + """The schema listing rejects unauthenticated callers.""" + response = client.get('/api/collector/_schemas') + assert response.status_code == 401 + + +def test_schemas_lists_computers(client, db, auth_headers): + """Computers plugin exposes a collector schema keyed by hostname.""" + response = client.get('/api/collector/_schemas', headers=auth_headers) + assert response.status_code == 200 + schemas = response.get_json()['data']['schemas'] + assert 'computers' in schemas + assert schemas['computers']['identityfield'] == 'hostname' + + +def test_missing_key_rejected(client, db, collector_key, computer_assettype): + """No API key -> 401.""" + response = client.post('/api/collector/computers', + json={'hostname': 'WJPC001'}) + assert response.status_code == 401 + + +def test_unknown_plugin_404(client, db, collector_key): + """A plugin with no collector schema returns 404.""" + response = client.post('/api/collector/nosuchplugin', + json={'hostname': 'x'}, + headers={'X-API-Key': KEY}) + assert response.status_code == 404 + + +def test_missing_identity_rejected(client, db, collector_key, computer_assettype): + """Missing the identity field is a validation error.""" + response = client.post('/api/collector/computers', + json={'currentuser': 'someone'}, + headers={'X-API-Key': KEY}) + assert response.status_code == 400 + + +def test_create_then_idempotent_update(client, db, collector_key, + computer_assettype): + """First post creates; second post with same hostname updates (no dup).""" + from plugins.computers.models import Computer + + payload = {'hostname': 'WJPC100', 'currentuser': 'alice', + 'serialnumber': 'SN-100'} + + first = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert first.status_code == 200, first.get_json() + data = first.get_json()['data'] + assert data['action'] == 'created' + assert data['assetid'] is not None + assert data['identityvalue'] == 'WJPC100' + + payload['currentuser'] = 'bob' + second = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert second.status_code == 200 + assert second.get_json()['data']['action'] == 'updated' + + with client.application.app_context(): + comps = Computer.query.filter(Computer.hostname.ilike('WJPC100')).all() + assert len(comps) == 1 + assert comps[0].loggedinuser == 'bob' + + +def test_per_plugin_key_overrides_shared(client, db, app, computer_assettype): + """COLLECTOR_API_KEY_ takes precedence over the shared key.""" + app.config['COLLECTOR_API_KEY'] = 'sharedkey' + app.config['COLLECTOR_API_KEY_COMPUTERS'] = 'computerskey' + try: + # Shared key now rejected for this plugin. + rejected = client.post('/api/collector/computers', + json={'hostname': 'WJPC200'}, + headers={'X-API-Key': 'sharedkey'}) + assert rejected.status_code == 401 + + accepted = client.post('/api/collector/computers', + json={'hostname': 'WJPC200'}, + headers={'X-API-Key': 'computerskey'}) + assert accepted.status_code == 200 + finally: + app.config.pop('COLLECTOR_API_KEY_COMPUTERS', None) + app.config['COLLECTOR_API_KEY'] = None + + +def test_complete_asset_payload_maps_enrollment_data(client, db, collector_key, + computer_assettype): + """A GE-Enforce-shaped payload maps machinenumber/pctype/vendor/model/os.""" + from shopdb.core.models import OperatingSystem + from plugins.computers.models import Computer, ComputerType + + # Seed the controlled-vocab rows the collector looks up. + db.session.add(ComputerType(computertype='Shopfloor PC')) + db.session.add(OperatingSystem(osname='Windows 11')) + db.session.commit() + + payload = { + 'hostname': 'WJSF1234', + 'machinenumber': '0615', + 'pctype': 'gea-shopfloor-collections', + 'serialnumber': 'SN-ENROLL', + 'vendorname': 'Dell', + 'modelnumber': 'OptiPlex 7090', + 'osname': 'Windows 11', + } + resp = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['action'] == 'created' + + with client.application.app_context(): + comp = Computer.query.filter(Computer.hostname.ilike('WJSF1234')).first() + assert comp is not None + assert comp.asset.assetnumber == '0615' # machinenumber -> assetnumber + assert comp.asset.serialnumber == 'SN-ENROLL' + assert comp.computertype.computertype == 'Shopfloor PC' # pctype mapped + assert comp.vendor.vendor == 'Dell' # created + assert comp.model.modelnumber == 'OptiPlex 7090' # created + assert comp.operatingsystem.osname == 'Windows 11' + + +def test_placeholder_machinenumber_9999_falls_back_to_hostname(client, db, + collector_key, + computer_assettype): + """The 9999 imaging placeholder is ignored; assetnumber falls back to host.""" + resp = client.post('/api/collector/computers', + json={'hostname': 'WJSF9999', 'machinenumber': '9999'}, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + from plugins.computers.models import Computer + with client.application.app_context(): + comp = Computer.query.filter(Computer.hostname.ilike('WJSF9999')).first() + assert comp.asset.assetnumber == 'WJSF9999' + + +def test_internal_error_message_is_generic(client, app, db, collector_key): + """An unexpected upsert failure returns a generic 500, not the exception text.""" + pm = app.extensions['plugin_manager'] + plugin = pm.get_all_plugins()['computers'] + original = plugin.apply_collector_payload + + def boom(payload): + raise RuntimeError('secret internal dsn leaked here') + + plugin.apply_collector_payload = boom + try: + resp = client.post('/api/collector/computers', + json={'hostname': 'WJPC500'}, + headers={'X-API-Key': KEY}) + finally: + plugin.apply_collector_payload = original + + assert resp.status_code == 500 + message = resp.get_json()['data']['error']['message'] + assert message == 'Internal error processing collector payload' + assert 'secret internal dsn' not in message + + +def test_generic_querystring_api_key_rejected(client, db, collector_key, + computer_assettype): + """The dropped ?api_key= querystring fallback no longer authenticates.""" + resp = client.post('/api/collector/computers?api_key=' + KEY, + json={'hostname': 'WJPCQS'}) + assert resp.status_code == 401 + + +def test_legacy_querystring_api_key_rejected(client, db, collector_key): + """Header-only auth on the legacy endpoints too: querystring key is rejected.""" + resp = client.get('/api/collector/status?api_key=' + KEY) + assert resp.status_code == 401 + + +def test_legacy_header_api_key_accepted(client, db, collector_key): + """The X-API-Key header still authenticates the legacy endpoints.""" + resp = client.get('/api/collector/status', headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + + +# --- Managed collector service tokens ------------------------------------ + +def _mint_collector_token(client, auth_headers, scopes): + """Mint a managed token with the given scopes as the logged-in admin.""" + resp = client.post('/api/apitokens', + json={'name': 'collector svc', 'scopes': scopes}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['secret'] + + +def test_collector_token_via_x_api_key(client, db, auth_headers, + computer_assettype): + """A collector-scoped managed token authorizes ingest via X-API-Key, with + no env key set.""" + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + resp = client.post('/api/collector/computers', + json={'hostname': 'WJTOK01'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['action'] == 'created' + + +def test_collector_token_via_bearer(client, db, auth_headers, + computer_assettype): + """The same token also works on the standard Bearer PAT transport.""" + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + resp = client.post('/api/collector/computers', + json={'hostname': 'WJTOK02'}, + headers={'Authorization': f'Bearer {secret}'}) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['action'] == 'created' + + +def test_collector_token_stamps_lastusedat(client, db, auth_headers, + computer_assettype): + """Using a collector token stamps lastusedat.""" + from shopdb.core.models import ApiToken + + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.lastusedat is None + + client.post('/api/collector/computers', json={'hostname': 'WJTOK03'}, + headers={'X-API-Key': secret}) + db.session.expire_all() + token = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first() + assert token.lastusedat is not None + + +def test_unscoped_token_rejected_by_collector(client, db, auth_headers, + collector_key, computer_assettype): + """An unscoped PAT is NOT a collector token: it lacks the collector.ingest + scope, so the collector API rejects it (env-key semantics unchanged).""" + secret = client.post('/api/apitokens', json={'name': 'full'}, + headers=auth_headers).get_json()['data']['secret'] + resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK04'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 401 + + +def test_wrong_scoped_token_rejected_by_collector(client, db, auth_headers, + collector_key, + computer_assettype): + """A PAT scoped to some OTHER permission is not a collector token.""" + secret = _mint_collector_token(client, auth_headers, ['applications.create']) + resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK05'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 401 + + +def test_revoked_collector_token_rejected(client, db, auth_headers, + collector_key, computer_assettype): + """A revoked collector token is rejected on both transports.""" + from shopdb.core.models import ApiToken + + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + tokenid = ApiToken.query.filter_by( + tokenhash=ApiToken.hash_secret(secret)).first().tokenid + assert client.delete(f'/api/apitokens/{tokenid}', + headers=auth_headers).status_code == 200 + + viakey = client.post('/api/collector/computers', json={'hostname': 'WJTOK06'}, + headers={'X-API-Key': secret}) + assert viakey.status_code == 401 + viabearer = client.post('/api/collector/computers', + json={'hostname': 'WJTOK06'}, + headers={'Authorization': f'Bearer {secret}'}) + assert viabearer.status_code == 401 + + +def test_collector_token_contained_to_collector_api(client, db, admin_user, + auth_headers): + """Containment: a collector-scoped token is denied on a permission-gated + write (applications.create) AND a role-gated write (vendors, require_role).""" + from shopdb.core.models import Application, Vendor + + secret = _mint_collector_token(client, auth_headers, ['collector.ingest']) + + perm_gated = client.post('/api/applications', json={'appname': 'Nope'}, + headers={'Authorization': f'Bearer {secret}'}) + assert perm_gated.status_code == 403 + assert Application.query.filter_by(appname='Nope').first() is None + + role_gated = client.post('/api/vendors', json={'vendor': 'Nope'}, + headers={'Authorization': f'Bearer {secret}'}) + assert role_gated.status_code == 403 + assert Vendor.query.filter_by(vendor='Nope').first() is None + + +def test_env_key_still_works_alongside_tokens(client, db, collector_key, + computer_assettype): + """Regression: the env key path is unchanged when tokens are in play.""" + resp = client.post('/api/collector/computers', json={'hostname': 'WJENV01'}, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + assert resp.get_json()['data']['action'] == 'created' + + +# -- metrology measuring-tool auto-link ------------------------------------ +# A CMM/Keyence/Genspect/wax-trace PC stays a shopfloor PC, but the collector +# creates the attached measuring instrument as a MeasuringTool asset and links +# the PC to it with a directional 'controls' relationship. + +@pytest.fixture +def measuringtool_env(db): + """Seed the measuring_tool asset type + 'controls' relationship type the + metrology collector path links through.""" + from shopdb.core.models import AssetType, RelationshipType + db.session.add(AssetType(assettype='measuring_tool', + pluginname='measuringtools', + tablename='measuringtools', + description='Metrology instruments')) + db.session.add(RelationshipType(relationshiptype='controls', + description='Operational authority', + isdirectional=True)) + db.session.commit() + + +def test_metrology_pctype_creates_and_links_measuringtool( + client, db, collector_key, computer_assettype, measuringtool_env): + """A CMM PC gets a MeasuringTool asset + controls link; the CMM tool type + is auto-created (it is not in the measuringtools starter seed).""" + from plugins.computers.models import Computer + from plugins.measuringtools.models import MeasuringTool, MeasuringToolType + from shopdb.core.models import AssetRelationship + + payload = {'hostname': 'WJCMM01', 'pctype': 'gea-shopfloor-cmm'} + resp = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200, resp.get_json() + data = resp.get_json()['data'] + assert data['measuringtoollinkcount'] == 1 + link = data['measuringtoollinks'][0] + assert link['relationshiptype'] == 'controls' + assert link['measuringtooltype'] == 'CMM' + + with client.application.app_context(): + tools = MeasuringTool.query.all() + assert len(tools) == 1 + assert MeasuringToolType.query.filter_by(name='CMM').first() is not None + pc = Computer.query.filter(Computer.hostname.ilike('WJCMM01')).first() + rels = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, label='collector:measuringtool').all() + assert len(rels) == 1 + assert rels[0].targetassetid == tools[0].assetid + assert rels[0].isactive + + +def test_metrology_link_idempotent(client, db, collector_key, + computer_assettype, measuringtool_env): + """Re-pushing the same metrology PC does not duplicate the tool asset.""" + from plugins.measuringtools.models import MeasuringTool + + payload = {'hostname': 'WJCMM02', 'pctype': 'gea-shopfloor-cmm'} + for _ in range(2): + resp = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + with client.application.app_context(): + assert MeasuringTool.query.count() == 1 + + +def test_nonmetrology_pctype_makes_no_tool(client, db, collector_key, + computer_assettype, measuringtool_env): + """A plain shopfloor PC gets no measuring-tool asset.""" + from plugins.measuringtools.models import MeasuringTool + + payload = {'hostname': 'WJPC900', 'pctype': 'gea-shopfloor-collections'} + resp = client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + assert resp.get_json()['data']['measuringtoollinkcount'] == 0 + with client.application.app_context(): + assert MeasuringTool.query.count() == 0 + + +def test_repurposed_pc_archives_tool_link(client, db, collector_key, + computer_assettype, measuringtool_env): + """A metrology PC re-imaged to a non-metrology type has its collector tool + link archived (tool asset kept, relationship deactivated).""" + from plugins.measuringtools.models import MeasuringTool + from shopdb.core.models import AssetRelationship + + host = {'hostname': 'WJCMM03'} + client.post('/api/collector/computers', + json={**host, 'pctype': 'gea-shopfloor-cmm'}, + headers={'X-API-Key': KEY}) + resp = client.post('/api/collector/computers', + json={**host, 'pctype': 'gea-shopfloor-collections'}, + headers={'X-API-Key': KEY}) + assert resp.status_code == 200 + assert resp.get_json()['data']['measuringtoollinkcount'] == 0 + with client.application.app_context(): + # asset preserved (may carry calibration history), link deactivated + assert MeasuringTool.query.count() == 1 + rels = AssetRelationship.query.filter_by( + label='collector:measuringtool').all() + assert len(rels) == 1 + assert rels[0].isactive is False diff --git a/tests/test_core/test_collector_printers.py b/tests/test_core/test_collector_printers.py new file mode 100644 index 00000000..ac48bd93 --- /dev/null +++ b/tests/test_core/test_collector_printers.py @@ -0,0 +1,199 @@ +"""Tests for the computers collector PC->printer relationship sync. + +A collector payload carrying defaultprinter/printers creates AssetRelationship +rows linking the PC asset to the resolved printer assets ('defaultprinter' for +the default, 'connectedto' for the rest). Stale collector links archive on +re-report; manual links survive; unresolved identifiers warn without failing. +""" + +import pytest + +from shopdb.core.models import ( + Asset, AssetType, AssetRelationship, RelationshipType, CommunicationType, + Communication) +from plugins.printers.models import Printer + +KEY = 'testcollectorkey' + + +@pytest.fixture +def collector_key(app): + old = app.config.get('COLLECTOR_API_KEY') + app.config['COLLECTOR_API_KEY'] = KEY + yield KEY + app.config['COLLECTOR_API_KEY'] = old + + +@pytest.fixture +def scene(db): + """Computer + printer asset types, the two relationship types, and three + printer assets identified by windowsname / hostname / IP.""" + pc_type = AssetType(assettype='computer', pluginname='computers', + tablename='computers') + pr_type = AssetType(assettype='printer', pluginname='printers', + tablename='printers') + dp_type = RelationshipType(relationshiptype='defaultprinter', + description='PC to default printer', + isdirectional=True) + ct_type = RelationshipType(relationshiptype='connectedto', + description='Network link', isdirectional=False) + ip_comtype = CommunicationType(comtype='IP') + db.session.add_all([pc_type, pr_type, dp_type, ct_type, ip_comtype]) + db.session.flush() + + # Printer A: matched by windowsname. Printer B: by hostname. Printer C: by IP. + a_asset = Asset(assetnumber='PRN-A', name='Materials HP', + assettypeid=pr_type.assettypeid, isactive=True) + b_asset = Asset(assetnumber='PRN-B', name='Front Office', + assettypeid=pr_type.assettypeid, isactive=True) + c_asset = Asset(assetnumber='PRN-C', name='Shipping Label', + assettypeid=pr_type.assettypeid, isactive=True) + db.session.add_all([a_asset, b_asset, c_asset]) + db.session.flush() + + db.session.add_all([ + Printer(assetid=a_asset.assetid, windowsname='HP-Materials'), + Printer(assetid=b_asset.assetid, hostname='prn-front.wjs.local'), + Printer(assetid=c_asset.assetid, windowsname='Zebra-Ship'), + ]) + db.session.add(Communication(assetid=c_asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress='10.1.2.3', isprimary=True)) + db.session.commit() + + return {'pc_type': pc_type, 'dp_type': dp_type, 'ct_type': ct_type, + 'a': a_asset, 'b': b_asset, 'c': c_asset} + + +def _post(client, payload): + return client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': KEY}) + + +def _pc_asset(db): + from plugins.computers.models import Computer + comp = Computer.query.first() + return comp.asset + + +def _active_links(db, pcid): + return AssetRelationship.query.filter_by( + sourceassetid=pcid, isactive=True).all() + + +def test_creates_default_and_connected_links(client, db, collector_key, scene): + """Default -> defaultprinter, others -> connectedto, resolved by three id forms.""" + resp = _post(client, { + 'hostname': 'WJPC-P1', + 'defaultprinter': 'HP-Materials', # windowsname + 'printers': ['prn-front.wjs.local', '10.1.2.3'], # hostname, IP + }) + assert resp.status_code == 200, resp.get_json() + data = resp.get_json()['data'] + assert data['printerlinkcount'] == 3 + assert not data['warnings'] + + pc = _pc_asset(db) + links = {(r.targetassetid, r.relationshiptype.relationshiptype) + for r in _active_links(db, pc.assetid)} + assert (scene['a'].assetid, 'defaultprinter') in links + assert (scene['b'].assetid, 'connectedto') in links + assert (scene['c'].assetid, 'connectedto') in links + + +def test_unresolved_identifier_warns_not_fails(client, db, collector_key, scene): + """An unknown printer identifier is a warning, the rest still link.""" + resp = _post(client, { + 'hostname': 'WJPC-P2', + 'defaultprinter': 'HP-Materials', + 'printers': ['does-not-exist'], + }) + assert resp.status_code == 200 + data = resp.get_json()['data'] + assert any('unresolved printer: does-not-exist' in w + for w in data['warnings']) + pc = _pc_asset(db) + assert data['printerlinkcount'] == 1 + assert len(_active_links(db, pc.assetid)) == 1 + + +def test_idempotent_no_duplicates(client, db, collector_key, scene): + """Re-report the same set: no new rows, still one link per printer.""" + payload = {'hostname': 'WJPC-P3', 'defaultprinter': 'HP-Materials', + 'printers': ['Zebra-Ship']} + assert _post(client, payload).status_code == 200 + assert _post(client, payload).status_code == 200 + + pc = _pc_asset(db) + all_rows = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).all() + assert len(all_rows) == 2 # one default + one connected, no dupes + assert all(r.isactive for r in all_rows) + + +def test_dropped_printer_is_archived(client, db, collector_key, scene): + """A printer that stops being reported loses its collector link.""" + _post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials', + 'printers': ['Zebra-Ship']}) + pc = _pc_asset(db) + assert len(_active_links(db, pc.assetid)) == 2 + + # Re-report without Zebra-Ship. + _post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials', + 'printers': []}) + active = _active_links(db, pc.assetid) + assert len(active) == 1 + assert active[0].targetassetid == scene['a'].assetid + # the dropped row still exists but is inactive + zebra = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first() + assert zebra is not None and zebra.isactive is False + + +def test_default_change_archives_old_default(client, db, collector_key, scene): + """Changing the default printer archives the previous defaultprinter link.""" + _post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'HP-Materials'}) + pc = _pc_asset(db) + _post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'prn-front.wjs.local'}) + + active = _active_links(db, pc.assetid) + assert len(active) == 1 + assert active[0].targetassetid == scene['b'].assetid + assert active[0].relationshiptype.relationshiptype == 'defaultprinter' + + +def test_manual_link_survives_collector_sync(client, db, collector_key, scene): + """A manually-created link (no collector label) is never archived.""" + # PC created first via a bare report. + _post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials', + 'printers': ['Zebra-Ship']}) + pc = _pc_asset(db) + + # Manual connectedto link PC -> printer B (label stays NULL). + manual = AssetRelationship( + sourceassetid=pc.assetid, targetassetid=scene['b'].assetid, + relationshiptypeid=scene['ct_type'].relationshiptypeid, notes='by hand') + db.session.add(manual) + db.session.commit() + manual_id = manual.relationshipid + + # Re-report dropping Zebra-Ship and never mentioning printer B. + _post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials', + 'printers': []}) + + surviving = db.session.get(AssetRelationship, manual_id) + assert surviving.isactive is True # manual link untouched + # Zebra (collector) archived. + zebra = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first() + assert zebra.isactive is False + + +def test_no_printer_keys_leaves_links_untouched(client, db, collector_key, scene): + """A report with no printer keys does not archive existing links.""" + _post(client, {'hostname': 'WJPC-P7', 'defaultprinter': 'HP-Materials'}) + pc = _pc_asset(db) + assert len(_active_links(db, pc.assetid)) == 1 + + # A plain update (no defaultprinter, no printers key) must not archive. + _post(client, {'hostname': 'WJPC-P7', 'currentuser': 'alice'}) + assert len(_active_links(db, pc.assetid)) == 1 diff --git a/tests/test_core/test_dashboard_widgets.py b/tests/test_core/test_dashboard_widgets.py new file mode 100644 index 00000000..3bcdff4c --- /dev/null +++ b/tests/test_core/test_dashboard_widgets.py @@ -0,0 +1,38 @@ +"""Tests for the dashboard-widgets hook consumer (/api/dashboard/widgets). + +Pins the wiring added for the BasePlugin.get_dashboard_widgets hook: the +endpoint aggregates enabled plugins' widgets and skips disabled ones. + +Plugin enabled-state is monkeypatched (not persisted) so these tests do not +mutate the shared instance/plugins.json registry file. +""" + + +def _widget_plugins(client, headers): + response = client.get('/api/dashboard/widgets', headers=headers) + assert response.status_code == 200, response.get_json() + widgets = response.get_json()['data'] + assert isinstance(widgets, list) + return widgets, {w.get('plugin') for w in widgets} + + +def test_widgets_endpoint_aggregates_enabled_plugins(app, client, auth_headers, + monkeypatch): + """An enabled plugin that implements the hook contributes a widget.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + widgets, plugins = _widget_plugins(client, auth_headers) + assert 'computers' in plugins # computers implements get_dashboard_widgets + positions = [w.get('position', 99) for w in widgets] + assert positions == sorted(positions) + + +def test_widgets_endpoint_skips_disabled_plugin(app, client, auth_headers, + monkeypatch): + """A disabled plugin's widgets drop out of the aggregate.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'computers') + + _, plugins = _widget_plugins(client, auth_headers) + assert 'computers' not in plugins diff --git a/tests/test_core/test_dashboarddefaults.py b/tests/test_core/test_dashboarddefaults.py new file mode 100644 index 00000000..34261d40 --- /dev/null +++ b/tests/test_core/test_dashboarddefaults.py @@ -0,0 +1,99 @@ +"""Tests for dashboard defaults (visitor-IP -> business-unit resolution).""" + +import pytest + + +@pytest.fixture +def businessunit(db): + from shopdb.core.models import BusinessUnit + bu = BusinessUnit(businessunit='Materials') + db.session.add(bu) + db.session.commit() + return bu + + +def test_create_and_resolve_visitor_location(client, db, auth_headers, businessunit): + """A mapped IP resolves to its business unit; explicit ipaddress param works.""" + created = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.40', + 'businessunitid': businessunit.businessunitid, + 'description': 'Materials lobby kiosk', + }, headers=auth_headers) + assert created.status_code == 201, created.get_json() + + resolved = client.get('/api/dashboarddefaults/visitor-location?ipaddress=10.20.30.40') + assert resolved.status_code == 200 + data = resolved.get_json()['data'] + assert data['businessunitid'] == businessunit.businessunitid + assert data['businessunit'] == 'Materials' + + +def test_unmapped_ip_resolves_to_null(client, db): + """An unmapped IP returns a null business unit, not an error.""" + resolved = client.get('/api/dashboarddefaults/visitor-location?ipaddress=1.2.3.4') + assert resolved.status_code == 200 + assert resolved.get_json()['data']['businessunitid'] is None + + +def test_duplicate_ip_rejected(client, db, auth_headers, businessunit): + """Mapping the same IP twice is a conflict.""" + payload = {'ipaddress': '10.0.0.9', 'businessunitid': businessunit.businessunitid} + first = client.post('/api/dashboarddefaults', json=payload, headers=auth_headers) + assert first.status_code == 201 + dup = client.post('/api/dashboarddefaults', json=payload, headers=auth_headers) + assert dup.status_code == 409 + + +def test_non_admin_cannot_create(client, db, member_headers, businessunit): + """A logged-in non-admin is forbidden from creating a mapping (RBAC).""" + resp = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.50', + 'businessunitid': businessunit.businessunitid, + }, headers=member_headers) + assert resp.status_code == 403 + + +def test_unauthenticated_cannot_create(client, db, businessunit): + """No token -> 401 on create.""" + resp = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.51', + 'businessunitid': businessunit.businessunitid, + }) + assert resp.status_code == 401 + + +def test_admin_can_update_and_delete(client, db, auth_headers, businessunit): + """Admin can PUT and DELETE a mapping.""" + created = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.60', + 'businessunitid': businessunit.businessunitid, + }, headers=auth_headers) + assert created.status_code == 201 + default_id = created.get_json()['data']['dashboarddefaultid'] + + updated = client.put(f'/api/dashboarddefaults/{default_id}', + json={'description': 'moved'}, headers=auth_headers) + assert updated.status_code == 200 + + deleted = client.delete(f'/api/dashboarddefaults/{default_id}', + headers=auth_headers) + assert deleted.status_code == 200 + + +def test_non_admin_cannot_update_or_delete(client, db, auth_headers, + member_headers, businessunit): + """A non-admin is forbidden from PUT and DELETE.""" + created = client.post('/api/dashboarddefaults', json={ + 'ipaddress': '10.20.30.70', + 'businessunitid': businessunit.businessunitid, + }, headers=auth_headers) + assert created.status_code == 201 + default_id = created.get_json()['data']['dashboarddefaultid'] + + put_resp = client.put(f'/api/dashboarddefaults/{default_id}', + json={'description': 'nope'}, headers=member_headers) + assert put_resp.status_code == 403 + + del_resp = client.delete(f'/api/dashboarddefaults/{default_id}', + headers=member_headers) + assert del_resp.status_code == 403 diff --git a/tests/test_core/test_email.py b/tests/test_core/test_email.py new file mode 100644 index 00000000..828ad7bd --- /dev/null +++ b/tests/test_core/test_email.py @@ -0,0 +1,270 @@ +"""Tests for the email service, forced password change, welcome email, and +on-demand report/alert delivery. + +smtplib is always patched so tests never touch the network. +""" + +import os +from unittest.mock import patch, MagicMock + +import pytest +from werkzeug.security import generate_password_hash + + +@pytest.fixture(autouse=True) +def _strip_smtp_env(monkeypatch): + """Remove any SMTP_* env so env-fallback cannot enable email unexpectedly.""" + for key in list(os.environ): + if key.startswith('SMTP_'): + monkeypatch.delenv(key, raising=False) + + +def _enable_smtp(db): + """Seed the minimum settings to make SMTP 'configured'.""" + from shopdb.core.models import Setting + from shopdb.core.api.settings import invalidate_settings_cache + + rows = { + 'smtp_enabled': ('true', 'boolean'), + 'smtp_host': ('smtp.test.local', 'string'), + 'smtp_port': ('587', 'integer'), + 'smtp_use_tls': ('true', 'boolean'), + 'smtp_from_address': ('noreply@test.local', 'string'), + 'smtp_from_name': ('ShopDB', 'string'), + 'alert_recipients': ('ops@test.local', 'string'), + } + for key, (value, valuetype) in rows.items(): + db.session.add(Setting(key=key, value=value, valuetype=valuetype, + category='email')) + db.session.commit() + invalidate_settings_cache() + + +def _make_user(db, username, password='temppass1', mustchange=False, + failedlogins=0): + from shopdb.core.models import User + user = User( + username=username, + email=f'{username}@test.local', + passwordhash=generate_password_hash(password), + mustchangepassword=mustchange, + failedlogins=failedlogins, + ) + db.session.add(user) + db.session.commit() + return user + + +def _login(client, username, password): + response = client.post('/api/auth/login', + json={'username': username, 'password': password}) + return response + + +# --------------------------------------------------------------------------- +# Mailer no-op behavior +# --------------------------------------------------------------------------- + +def test_mailer_noop_when_disabled(app, db): + """send_email is a no-op returning False when SMTP is disabled; no connect.""" + from shopdb.utils import mailer + with app.app_context(): + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + result = mailer.send_email('a@test.local', 'Hi', '

Hi

') + assert result is False + smtp.assert_not_called() + + +def test_mailer_sends_when_configured(app, db): + """send_email connects and sends when SMTP is configured (patched).""" + from shopdb.utils import mailer + with app.app_context(): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + smtp.return_value = instance + result = mailer.send_email('a@test.local', 'Hi', '

Hi

') + assert result is True + instance.sendmail.assert_called_once() + + +def test_mailer_never_logs_password(app, db): + """A send failure carrying the password is scrubbed in the returned error.""" + from shopdb.utils import mailer + with app.app_context(): + from shopdb.core.models import Setting + from shopdb.core.api.settings import invalidate_settings_cache + _enable_smtp(db) + db.session.add(Setting(key='smtp_password', value='s3cret', + valuetype='string', category='email')) + db.session.commit() + invalidate_settings_cache() + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + instance.sendmail.side_effect = RuntimeError('auth failed s3cret') + smtp.return_value = instance + ok, error = mailer.try_send('a@test.local', 'Hi', '

Hi

') + assert ok is False + assert 's3cret' not in error + + +# --------------------------------------------------------------------------- +# Test-email endpoint +# --------------------------------------------------------------------------- + +def test_test_email_requires_admin(client, db, member_headers): + response = client.post('/api/settings/test-email', + json={'to': 'x@test.local'}, headers=member_headers) + assert response.status_code == 403 + + +def test_test_email_graceful_when_unconfigured(client, db, auth_headers): + response = client.post('/api/settings/test-email', + json={'to': 'x@test.local'}, headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['sent'] is False + + +def test_test_email_sends_when_configured(client, db, auth_headers): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + smtp.return_value = MagicMock() + response = client.post('/api/settings/test-email', + json={'to': 'x@test.local'}, headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['sent'] is True + + +# --------------------------------------------------------------------------- +# Forced password change +# --------------------------------------------------------------------------- + +def test_login_surfaces_mustchangepassword(client, db): + _make_user(db, 'forceduser', password='temppass1', mustchange=True) + response = _login(client, 'forceduser', 'temppass1') + assert response.status_code == 200 + assert response.get_json()['data']['user']['mustchangepassword'] is True + + +def test_forced_change_clears_flag_and_lockout(client, db): + user = _make_user(db, 'forceduser', password='temppass1', mustchange=True, + failedlogins=3) + token = _login(client, 'forceduser', 'temppass1').get_json()['data']['access_token'] + headers = {'Authorization': f'Bearer {token}'} + # Forced case: new_password only, no current required. + response = client.post('/api/auth/change-password', + json={'new_password': 'brandnew123'}, headers=headers) + assert response.status_code == 200 + + from shopdb.core.models import User + refreshed = db.session.get(User, user.userid) + assert refreshed.mustchangepassword is False + assert refreshed.failedlogins == 0 + assert refreshed.lockeduntil is None + # New password works. + assert _login(client, 'forceduser', 'brandnew123').status_code == 200 + + +def test_selfservice_change_rejects_wrong_current(client, db): + _make_user(db, 'normaluser', password='rightpass1', mustchange=False) + token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token'] + headers = {'Authorization': f'Bearer {token}'} + response = client.post('/api/auth/change-password', + json={'current_password': 'wrongpass', + 'new_password': 'brandnew123'}, headers=headers) + assert response.status_code == 401 + + +def test_selfservice_change_succeeds_with_correct_current(client, db): + _make_user(db, 'normaluser', password='rightpass1', mustchange=False) + token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token'] + headers = {'Authorization': f'Bearer {token}'} + response = client.post('/api/auth/change-password', + json={'current_password': 'rightpass1', + 'new_password': 'brandnew123'}, headers=headers) + assert response.status_code == 200 + assert _login(client, 'normaluser', 'brandnew123').status_code == 200 + + +def test_change_password_rejects_short_password(client, db): + _make_user(db, 'normaluser', password='rightpass1', mustchange=False) + token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token'] + headers = {'Authorization': f'Bearer {token}'} + response = client.post('/api/auth/change-password', + json={'current_password': 'rightpass1', + 'new_password': 'short'}, headers=headers) + assert response.status_code == 400 + + +# --------------------------------------------------------------------------- +# New-user welcome +# --------------------------------------------------------------------------- + +def test_create_user_sets_mustchangepassword_and_sends(client, db, auth_headers): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + smtp.return_value = instance + response = client.post('/api/users', + json={'username': 'newhire', + 'email': 'newhire@test.local', + 'password': 'temppass1'}, + headers=auth_headers) + assert response.status_code == 201 + assert response.get_json()['data']['mustchangepassword'] is True + instance.sendmail.assert_called_once() + + +def test_create_user_survives_mail_failure(client, db, auth_headers): + """User is still created (with a warning) when the welcome email fails.""" + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + instance.sendmail.side_effect = RuntimeError('relay down') + smtp.return_value = instance + response = client.post('/api/users', + json={'username': 'newhire2', + 'email': 'newhire2@test.local', + 'password': 'temppass1'}, + headers=auth_headers) + assert response.status_code == 201 + assert 'warning' in response.get_json()['data'] + + +# --------------------------------------------------------------------------- +# On-demand report/alert delivery +# --------------------------------------------------------------------------- + +def test_email_report_requires_permission(client, db, member_headers): + response = client.post('/api/reports/email', + json={'subject': 'X', 'columns': [], 'rows': []}, + headers=member_headers) + assert response.status_code == 403 + + +def test_email_report_renders_and_sends(client, db, auth_headers): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + smtp.return_value = instance + response = client.post('/api/reports/email', + json={'subject': 'Warranty Report', + 'columns': [{'key': 'vendor', 'label': 'Vendor'}], + 'rows': [{'vendor': 'Dell'}], + 'to': 'boss@test.local'}, + headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['sent'] is True + instance.sendmail.assert_called_once() + + +def test_email_report_defaults_to_alert_recipients(client, db, auth_headers): + _enable_smtp(db) + with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp: + instance = MagicMock() + smtp.return_value = instance + response = client.post('/api/reports/email', + json={'subject': 'Toner', 'columns': [], 'rows': []}, + headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['sent'] is True diff --git a/tests/test_core/test_employees.py b/tests/test_core/test_employees.py new file mode 100644 index 00000000..aacb64ab --- /dev/null +++ b/tests/test_core/test_employees.py @@ -0,0 +1,26 @@ +"""Characterization tests for the employee directory endpoints. + +Written before extracting employees into a plugin. The wjf_employees DB is not +available under test, so these assert the DB-independent behaviors: validation +(400) and graceful failure (500 envelope, not an unhandled crash) when the +directory is unreachable. Behavior must be identical as a plugin blueprint. +""" + + +def test_search_requires_min_query(client, db): + """A too-short query is rejected before touching the directory DB.""" + resp = client.get('/api/employees/search?q=a') + assert resp.status_code == 400 + + +def test_lookup_requires_numeric_sso(client, db): + """Non-numeric SSO is rejected without touching the directory DB.""" + resp = client.get('/api/employees/lookup/abc') + assert resp.status_code == 400 + + +def test_search_degrades_when_directory_unreachable(client, db): + """With the directory DB unavailable, search returns a clean 500 envelope.""" + resp = client.get('/api/employees/search?q=smith') + assert resp.status_code == 500 + assert resp.get_json()['status'] == 'error' diff --git a/tests/test_core/test_identifiers.py b/tests/test_core/test_identifiers.py new file mode 100644 index 00000000..48b06e43 --- /dev/null +++ b/tests/test_core/test_identifiers.py @@ -0,0 +1,77 @@ +"""Tests for per-asset-type optional identifiers (gauge/maintenance refs). + +Pins two behaviors that shipped without coverage: +1. gaugelabreference + maintenancereference round-trip through each plugin's + asset create AND update endpoints (computer, printer, network). +2. The per-type identifier seed produces one setting per + (identifier x asset type) pair. +""" + +import pytest + +from shopdb.core.api.settings import IDENTIFIER_LABELS, IDENTIFIER_ASSETTYPES + + +@pytest.fixture +def asset_types(db): + """Seed the asset types the create endpoints look up by name.""" + from shopdb.core.models import AssetType + + for name in ('computer', 'printer', 'network_device'): + db.session.add(AssetType(assettype=name, pluginname=name, + tablename=name, description=name)) + db.session.commit() + + +# (endpoint prefix, extension key in the response, extra create fields) +PLUGIN_CASES = [ + ('/api/computers', 'computer', {}), + ('/api/printers', 'printer', {}), + ('/api/network', 'networkdevice', {}), +] + + +@pytest.mark.parametrize('prefix,extkey,extra', PLUGIN_CASES) +def test_gauge_maintenance_roundtrip(client, db, auth_headers, asset_types, + prefix, extkey, extra): + """Gauge/maintenance refs persist on create and update for each type.""" + payload = { + 'assetnumber': f'AST-{extkey}', + 'gaugelabreference': 'GL-1', + 'maintenancereference': 'MNT-1', + **extra, + } + created = client.post(prefix, json=payload, headers=auth_headers) + assert created.status_code == 201, created.get_json() + body = created.get_json()['data'] + assert body['gaugelabreference'] == 'GL-1' + assert body['maintenancereference'] == 'MNT-1' + + extid = body[extkey][f'{extkey}id'] if extkey != 'networkdevice' \ + else body[extkey]['networkdeviceid'] + + updated = client.put(f'{prefix}/{extid}', + json={'gaugelabreference': 'GL-2', + 'maintenancereference': 'MNT-2'}, + headers=auth_headers) + assert updated.status_code == 200, updated.get_json() + + fetched = client.get(f'{prefix}/{extid}', headers=auth_headers).get_json()['data'] + assert fetched['gaugelabreference'] == 'GL-2' + assert fetched['maintenancereference'] == 'MNT-2' + + +def test_per_type_identifier_seed_count(client, db, auth_headers): + """Seeding produces one boolean setting per identifier x asset type.""" + response = client.post('/api/settings/seed', headers=auth_headers) + assert response.status_code == 200 + + from shopdb.core.models import Setting + keys = {s.key for s in Setting.query.filter_by(category='identifiers').all()} + expected = { + f'identifier_{name}_{assettype}_enabled' + for name in IDENTIFIER_LABELS + for assettype in IDENTIFIER_ASSETTYPES + } + assert expected <= keys + assert len(expected) == len(IDENTIFIER_LABELS) * len(IDENTIFIER_ASSETTYPES) diff --git a/tests/test_core/test_import_mode.py b/tests/test_core/test_import_mode.py new file mode 100644 index 00000000..6d7011b6 --- /dev/null +++ b/tests/test_core/test_import_mode.py @@ -0,0 +1,209 @@ +"""Import-mode behavior: admin-gated legacy timestamp + event backdating. + +Covers deliverables of the import-API work: +- createddate/modifieddate passthrough on a reference entity (vendor) and an + asset plugin (computer), gated on admin + X-Import-Mode header. +- both negative cases: no header, and header sent by a non-admin. +- a natural-key exact-match lookup filter added for idempotent import. +- backdated USB checkout time in selfhosted mode. +""" + +import pytest +from werkzeug.security import generate_password_hash + +# Import the usb models at collection time so their tables register on the +# metadata before the per-test db fixture calls create_all. The usb plugin is +# disabled in the dev registry, so its tables would otherwise never be created. +from plugins.usb.models import USBDevice, USBCheckout # noqa: F401 + + +IMPORT_HEADER = {'X-Import-Mode': 'true'} +LEGACY_CREATED = '2020-01-05 08:30:00' +LEGACY_MODIFIED = '2021-06-07T14:15:16' + + +def _headers(auth_headers, importmode=True): + merged = dict(auth_headers) + if importmode: + merged.update(IMPORT_HEADER) + return merged + + +@pytest.fixture +def asset_types(db): + """Seed the asset types the plugin create endpoints look up by name.""" + from shopdb.core.models import AssetType + + for name in ('computer',): + db.session.add(AssetType(assettype=name, pluginname=name, + tablename=name, description=name)) + db.session.commit() + + +@pytest.fixture +def creator_headers(client, db): + """A NON-admin user granted only computers.create/edit, logged in. + + Proves import mode requires admin: this user carries the write permission + but must NOT get timestamp passthrough. + """ + from shopdb.core.models import User, Role, Permission + + perms = [] + for name in ('computers.create', 'computers.edit'): + p = Permission(name=name, description=name, category='computers') + db.session.add(p) + perms.append(p) + role = Role(rolename='pcoperator', description='PC operator') + role.permissions.extend(perms) + db.session.add(role) + db.session.flush() + + user = User(username='pcop', email='pcop@test.local', + passwordhash=generate_password_hash('testpass')) + user.roles.append(role) + db.session.add(user) + db.session.commit() + + response = client.post('/api/auth/login', + json={'username': 'pcop', 'password': 'testpass'}) + assert response.status_code == 200, response.get_json() + token = response.get_json()['data']['access_token'] + return {'Authorization': f'Bearer {token}'} + + +# --------------------------------------------------------------------------- +# Timestamp passthrough (positive) +# --------------------------------------------------------------------------- + +def test_admin_import_mode_preserves_vendor_timestamps(client, db, auth_headers): + """Admin + header: vendor keeps the legacy createddate/modifieddate.""" + payload = {'vendor': 'LegacyVendor', + 'createddate': LEGACY_CREATED, + 'modifieddate': LEGACY_MODIFIED} + response = client.post('/api/vendors', json=payload, + headers=_headers(auth_headers)) + assert response.status_code == 201, response.get_json() + + from shopdb.core.models import Vendor + v = Vendor.query.filter_by(vendor='LegacyVendor').first() + assert v.createddate.year == 2020 and v.createddate.month == 1 + assert v.createddate.day == 5 and v.createddate.hour == 8 + assert v.modifieddate.year == 2021 and v.modifieddate.month == 6 + + +def test_admin_import_mode_preserves_computer_asset_timestamps( + client, db, auth_headers, asset_types): + """Admin + header: the created asset row keeps its legacy createddate.""" + payload = {'assetnumber': 'PC-IMPORT-1', + 'hostname': 'legacy-pc', + 'createddate': LEGACY_CREATED, + 'modifieddate': LEGACY_MODIFIED} + response = client.post('/api/computers', json=payload, + headers=_headers(auth_headers)) + assert response.status_code == 201, response.get_json() + + from shopdb.core.models import Asset + asset = Asset.query.filter_by(assetnumber='PC-IMPORT-1').first() + assert asset.createddate.year == 2020 + assert asset.modifieddate.year == 2021 + + +# --------------------------------------------------------------------------- +# Timestamp passthrough (negative): ignored without header, or for non-admin +# --------------------------------------------------------------------------- + +def test_no_header_ignores_timestamps(client, db, auth_headers): + """Admin but NO X-Import-Mode header: createddate falls back to now.""" + payload = {'vendor': 'NoHeaderVendor', 'createddate': LEGACY_CREATED} + response = client.post('/api/vendors', json=payload, + headers=_headers(auth_headers, importmode=False)) + assert response.status_code == 201, response.get_json() + + from shopdb.core.models import Vendor + v = Vendor.query.filter_by(vendor='NoHeaderVendor').first() + assert v.createddate.year >= 2024 # server-stamped now, not the 2020 payload + + +def test_non_admin_ignores_timestamps(client, db, creator_headers, asset_types): + """Non-admin WITH the write permission + header: timestamps still ignored.""" + payload = {'assetnumber': 'PC-NONADMIN-1', + 'createddate': LEGACY_CREATED} + response = client.post('/api/computers', json=payload, + headers=_headers(creator_headers)) + assert response.status_code == 201, response.get_json() + + from shopdb.core.models import Asset + asset = Asset.query.filter_by(assetnumber='PC-NONADMIN-1').first() + assert asset.createddate.year >= 2024 # not backdated: caller is not admin + + +# --------------------------------------------------------------------------- +# Natural-key lookup filter (idempotency recipe support) +# --------------------------------------------------------------------------- + +def test_vendor_exact_lookup_filter(client, db, auth_headers): + """GET /api/vendors?vendor= exact-matches one vendor for lookup.""" + for name in ('Acme', 'AcmeTools', 'Beta'): + resp = client.post('/api/vendors', json={'vendor': name}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + + listed = client.get('/api/vendors?vendor=Acme', headers=auth_headers) + assert listed.status_code == 200 + rows = listed.get_json()['data'] + assert len(rows) == 1 + assert rows[0]['vendor'] == 'Acme' + + +# --------------------------------------------------------------------------- +# Backdated USB checkout (selfhosted mode) +# +# The usb plugin is disabled in the dev registry, so its blueprint may not be +# registered. Exercise the selfhosted layer directly inside a request context +# carrying the admin JWT + import header, which is what the route delegates to. +# --------------------------------------------------------------------------- + +def _seed_usb_device(db, serialnumber): + from plugins.usb.models import USBDevice + device = USBDevice(serialnumber=serialnumber, ischeckedout=False, isactive=True) + db.session.add(device) + db.session.commit() + return device + + +def test_usb_backdated_checkout(app, db, auth_headers): + """Admin + header: a USB checkout records the historical checkouttime.""" + from plugins.usb.api import selfhosted + from plugins.usb.models import USBDevice, USBCheckout + + device = _seed_usb_device(db, 'USB-IMPORT-1') + headers = _headers(auth_headers) + with app.test_request_context('/api/usb/USB-IMPORT-1/checkout', + method='POST', headers=headers): + resp = selfhosted.checkout_device( + 'USB-IMPORT-1', + {'badge': '570005354', 'checkouttime': '2020-03-04 09:10:11'}) + assert resp.status_code == 201, resp.get_json() + + row = USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid).first() + assert row.checkouttime.year == 2020 and row.checkouttime.month == 3 + reloaded = db.session.get(USBDevice, device.usbdeviceid) + assert reloaded.currentcheckoutdate.year == 2020 + + +def test_usb_checkout_ignores_backdate_without_header(app, db, auth_headers): + """No import header: the checkouttime override is ignored, uses now.""" + from plugins.usb.api import selfhosted + from plugins.usb.models import USBDevice, USBCheckout + + device = _seed_usb_device(db, 'USB-IMPORT-2') + with app.test_request_context('/api/usb/USB-IMPORT-2/checkout', + method='POST', headers=auth_headers): + resp = selfhosted.checkout_device( + 'USB-IMPORT-2', + {'badge': '570005354', 'checkouttime': '2020-03-04 09:10:11'}) + assert resp.status_code == 201, resp.get_json() + + row = USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid).first() + assert row.checkouttime.year >= 2024 # server now, not the 2020 override diff --git a/tests/test_core/test_knowledgebase.py b/tests/test_core/test_knowledgebase.py new file mode 100644 index 00000000..6cb2c20f --- /dev/null +++ b/tests/test_core/test_knowledgebase.py @@ -0,0 +1,34 @@ +"""Characterization tests for the Knowledge Base API. + +Written before extracting Knowledge Base into a plugin (pinning-flask-behavior): +the endpoints must behave identically whether KB is a core blueprint or a plugin +blueprint, since both register at /api/knowledgebase. +""" + + +def test_create_list_get_article(client, db, auth_headers): + """Create an article, see it in the list, fetch it by id.""" + created = client.post('/api/knowledgebase', + json={'shortdescription': 'How to reset a printer', + 'linkurl': 'https://kb.example/printer-reset', + 'keywords': 'printer reset'}, + headers=auth_headers) + assert created.status_code == 201, created.get_json() + linkid = created.get_json()['data']['linkid'] + + listing = client.get('/api/knowledgebase', headers=auth_headers) + assert listing.status_code == 200 + ids = [a['linkid'] for a in listing.get_json()['data']] + assert linkid in ids + + fetched = client.get(f'/api/knowledgebase/{linkid}', headers=auth_headers) + assert fetched.status_code == 200 + assert fetched.get_json()['data']['shortdescription'] == 'How to reset a printer' + + +def test_create_requires_shortdescription(client, db, auth_headers): + """shortdescription is required.""" + resp = client.post('/api/knowledgebase', + json={'linkurl': 'https://kb.example/x'}, + headers=auth_headers) + assert resp.status_code == 400 diff --git a/tests/test_core/test_location_code.py b/tests/test_core/test_location_code.py new file mode 100644 index 00000000..cb809eca --- /dev/null +++ b/tests/test_core/test_location_code.py @@ -0,0 +1,27 @@ +"""Unit tests for the location operation-code derivation. + +Locations carry no dedicated code column, so the operation code is the leading +whitespace-delimited token of the location name (e.g. '0615 Blisk Inspection' +-> '0615'). Printed asset labels for measuring tools encode this so every tool +at one operation shares one code. +""" + +from shopdb.core.models.location import derive_locationcode + + +def test_derive_leading_numeric_token(): + assert derive_locationcode('0615 Blisk Inspection') == '0615' + + +def test_derive_single_token_name(): + assert derive_locationcode('Lab') == 'Lab' + + +def test_derive_strips_surrounding_whitespace(): + assert derive_locationcode(' 0700 Final ') == '0700' + + +def test_derive_none_when_blank(): + assert derive_locationcode('') is None + assert derive_locationcode(' ') is None + assert derive_locationcode(None) is None diff --git a/tests/test_core/test_model_image.py b/tests/test_core/test_model_image.py new file mode 100644 index 00000000..5ed8a996 --- /dev/null +++ b/tests/test_core/test_model_image.py @@ -0,0 +1,149 @@ +"""Tests for vendor-model photo upload, replace, delete, and serve. + +Covers the core model-image endpoints: admin-gated upload/delete, the public +serve route, one-image-per-model replace semantics, and the guarantee that +deleting a model whose imageurl is an external URL clears the field without +touching the filesystem. +""" + +import io +import os + +from shopdb.core.models import Model +from shopdb.core.api.models import MODEL_IMAGE_URL_PREFIX + + +def _make_model(db, imageurl=None): + m = Model(modelnumber='TESTMODEL-1', imageurl=imageurl) + db.session.add(m) + db.session.commit() + return m + + +def test_upload_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot upload a model image.""" + m = _make_model(db) + data = {'file': (io.BytesIO(b''), 'photo.svg')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=member_headers) + assert resp.status_code == 403 + assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_upload_sets_imageurl_and_file_exists(client, db, auth_headers, app): + """Admin upload writes the served URL and the file lands in the instance dir.""" + m = _make_model(db) + payload = b'\x89PNG\r\n\x1a\nfake-png-bytes' + data = {'file': (io.BytesIO(payload), 'photo.png')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + + expected_url = f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png' + assert resp.get_json()['data']['imageurl'] == expected_url + + refreshed = db.session.get(Model, m.modelnumberid) + assert refreshed.imageurl == expected_url + + path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png') + assert os.path.exists(path) + with open(path, 'rb') as handle: + assert handle.read() == payload + + +def test_upload_rejects_invalid_extension(client, db, auth_headers): + """A disallowed file extension is rejected.""" + m = _make_model(db) + data = {'file': (io.BytesIO(b'MZ...'), 'photo.exe')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR' + + +def test_upload_missing_model_is_404(client, db, auth_headers): + """Uploading to a nonexistent model id is a 404.""" + data = {'file': (io.BytesIO(b''), 'photo.svg')} + resp = client.post('/api/models/999999/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 404 + + +def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app): + """Re-upload with a different extension deletes the prior file.""" + m = _make_model(db) + first = {'file': (io.BytesIO(b'first'), 'photo.png')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=first, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200 + oldpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png') + assert os.path.exists(oldpath) + + second = {'file': (io.BytesIO(b'second'), 'photo.jpg')} + resp = client.post(f'/api/models/{m.modelnumberid}/image', data=second, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200 + + # Old .png is gone; new .jpg exists and imageurl points at it. + assert not os.path.exists(oldpath) + newpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.jpg') + assert os.path.exists(newpath) + refreshed = db.session.get(Model, m.modelnumberid) + assert refreshed.imageurl == f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.jpg' + + +def test_serve_returns_bytes(client, db, auth_headers): + """The public serve route returns the uploaded bytes without auth.""" + m = _make_model(db) + payload = b'\x89PNG\r\n\x1a\nserved-bytes' + data = {'file': (io.BytesIO(payload), 'photo.png')} + client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + + served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png') + assert served.status_code == 200 + assert served.get_data() == payload + + +def test_delete_clears_field_and_removes_file(client, db, auth_headers, app): + """Delete clears imageurl and removes the owned file.""" + m = _make_model(db) + data = {'file': (io.BytesIO(b'bytes'), 'photo.png')} + client.post(f'/api/models/{m.modelnumberid}/image', data=data, + content_type='multipart/form-data', headers=auth_headers) + path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png') + assert os.path.exists(path) + + resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['imageurl'] is None + assert not os.path.exists(path) + + refreshed = db.session.get(Model, m.modelnumberid) + assert refreshed.imageurl is None + + +def test_delete_external_url_clears_field_without_filesystem_error(client, db, auth_headers): + """Delete on a model whose imageurl is an external URL just clears the field.""" + m = _make_model(db, imageurl='https://example.com/product.png') + resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['imageurl'] is None + + refreshed = db.session.get(Model, m.modelnumberid) + assert refreshed.imageurl is None + + +def test_delete_legacy_path_clears_field_without_filesystem_error(client, db, auth_headers): + """Delete on a shipped /images/models/* path clears the field, touches no disk.""" + m = _make_model(db, imageurl='/images/models/machines/legacy.png') + resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['imageurl'] is None + + +def test_delete_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot delete a model image.""" + m = _make_model(db, imageurl='https://example.com/product.png') + resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=member_headers) + assert resp.status_code == 403 diff --git a/tests/test_core/test_permissions_catalog.py b/tests/test_core/test_permissions_catalog.py new file mode 100644 index 00000000..8f1b2d48 --- /dev/null +++ b/tests/test_core/test_permissions_catalog.py @@ -0,0 +1,90 @@ +"""Tests for the get_permissions hook consumer chain (contract 0.10.0). + +Pins full_permission_catalog() and its consumers: the core catalog holds no +plugin permissions, an enabled plugin's permissions merge in and drop out when +disabled, seeding is idempotent, API-token scope validation follows the enabled +set, and the role grid groups plugin categories. + +Plugin enabled-state is monkeypatched (not persisted) so these tests do not +mutate the shared instance/plugins.json registry file. +""" + +from shopdb.core.models import Permission, full_permission_catalog +from shopdb.core.models.apitoken import ApiToken + +# Permission-owning plugin domains that must NOT live in the core catalog. +PLUGIN_PREFIXES = ('machines.', 'computers.', 'printers.', 'network.', 'kb.', + 'notifications.', 'usb.', 'warranty.', 'measuringtools.') + + +def test_core_catalog_excludes_plugin_permissions(): + """Permission.CORE_PERMISSIONS holds only genuinely core permissions.""" + names = {n for n, _d, _c in Permission.CORE_PERMISSIONS} + for name in names: + assert not name.startswith(PLUGIN_PREFIXES), ( + f'{name} is a plugin permission and must move to its plugin hook') + # Core sets stay put. + assert 'assets.edit' in names + assert 'settings.edit' in names + assert 'collector.ingest' in names + + +def test_enabled_plugin_permissions_merge(app, monkeypatch): + """An enabled plugin's permissions appear in full_permission_catalog().""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + with app.app_context(): + names = {n for n, _d, _c in full_permission_catalog()} + assert 'machines.edit' in names + assert 'printers.edit' in names + # core still present alongside plugin permissions + assert 'assets.edit' in names + + +def test_disabled_plugin_permissions_drop_out(app, monkeypatch): + """A disabled plugin's permissions disappear from the catalog.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', + lambda name: name != 'machines') + with app.app_context(): + names = {n for n, _d, _c in full_permission_catalog()} + assert 'machines.edit' not in names + assert 'printers.edit' in names # other plugins still enabled + + +def test_seed_is_idempotent(app, db, monkeypatch): + """Seeding the full catalog twice creates each permission once.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + with app.app_context(): + first = Permission.seed() + db.session.commit() + assert first > len(Permission.CORE_PERMISSIONS) # plugins contributed + second = Permission.seed() + db.session.commit() + assert second == 0 + + +def test_token_scope_validation_follows_enabled_set(app, monkeypatch): + """A plugin permission is an accepted scope only while the plugin is enabled.""" + pm = app.extensions['plugin_manager'] + with app.app_context(): + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + assert ApiToken.unknown_scope_names(['machines.edit']) == [] + assert ApiToken.unknown_scope_names(['assets.edit']) == [] + + monkeypatch.setattr(pm.registry, 'is_enabled', + lambda name: name != 'machines') + assert ApiToken.unknown_scope_names(['machines.edit']) == ['machines.edit'] + + +def test_list_permissions_endpoint_groups_plugin_category( + app, client, auth_headers, monkeypatch): + """GET /api/users/permissions surfaces enabled plugins' categories.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + response = client.get('/api/users/permissions', headers=auth_headers) + assert response.status_code == 200, response.get_json() + grouped = response.get_json()['data']['grouped'] + assert 'assets' in grouped # core category always present + assert 'machines' in grouped # enabled plugin category merged in diff --git a/tests/test_core/test_plugins_enabled.py b/tests/test_core/test_plugins_enabled.py new file mode 100644 index 00000000..8eaf15e8 --- /dev/null +++ b/tests/test_core/test_plugins_enabled.py @@ -0,0 +1,29 @@ +"""GET /api/plugins/enabled: the flat enabled-plugin-name list that drives +frontend route gating (ADR-009). + +Pins two properties: the endpoint is reachable anonymously and returns a +plain array of names, and a disabled plugin's name drops out of that array +(so its frontend routes gate off), mirroring the search-disabled pattern. +""" + + +def test_enabled_anonymous_returns_array(client): + """No auth required (jwt-optional): returns a flat array of name strings.""" + resp = client.get('/api/plugins/enabled') + assert resp.status_code == 200, resp.get_json() + data = resp.get_json()['data'] + assert isinstance(data, list) + assert all(isinstance(name, str) for name in data) + + +def test_disabled_plugin_absent(app, client, monkeypatch): + """A disabled plugin's name is missing; enabled ones remain present.""" + pm = app.extensions['plugin_manager'] + # Pretend printers is enabled but usb is not. + monkeypatch.setattr(pm.registry, 'get_enabled_plugins', + lambda: ['printers', 'computers']) + resp = client.get('/api/plugins/enabled') + assert resp.status_code == 200, resp.get_json() + data = resp.get_json()['data'] + assert 'printers' in data + assert 'usb' not in data diff --git a/tests/test_core/test_pluginui.py b/tests/test_core/test_pluginui.py new file mode 100644 index 00000000..b2186011 --- /dev/null +++ b/tests/test_core/test_pluginui.py @@ -0,0 +1,144 @@ +"""Tests for the ADR-010 frontend-contribution consumers (/api/pluginui/*). + +Pins the wiring added for the four data-only presentation hooks +(get_settings_cards, get_asset_panels, get_map_overlays, +get_asset_presentation): each endpoint aggregates enabled plugins' +contributions, tags them with the plugin name, and drops disabled plugins. +Also asserts the pilot plugins (warranty, measuringtools) contribute. + +Plugin enabled-state is monkeypatched (not persisted) so these tests do not +mutate the shared instance/plugins.json registry file. +""" + + +def _data(client, path, headers): + response = client.get(path, headers=headers) + assert response.status_code == 200, response.get_json() + payload = response.get_json()['data'] + assert isinstance(payload, list) + return payload + + +# ============================================================================= +# settings-cards +# ============================================================================= + +def test_settings_cards_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch): + """measuringtools contributes its settings card, tagged + position-sorted.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + cards = _data(client, '/api/pluginui/settings-cards', auth_headers) + plugins = {c.get('plugin') for c in cards} + assert 'measuringtools' in plugins + card = next(c for c in cards if c['plugin'] == 'measuringtools') + assert card['group'] == 'Measuring Tools' + assert card['to'] == '/settings/measuringtooltypes' + positions = [c.get('position', 99) for c in cards] + assert positions == sorted(positions) + + +def test_settings_cards_skip_disabled_plugin(app, client, auth_headers, monkeypatch): + """A disabled measuringtools drops its settings card.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools') + + cards = _data(client, '/api/pluginui/settings-cards', auth_headers) + assert 'measuringtools' not in {c.get('plugin') for c in cards} + + +# ============================================================================= +# asset-panels +# ============================================================================= + +def _make_asset(db): + from shopdb.core.models import Asset, AssetType + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer', pluginname='computer', + tablename='computer', description='c') + db.session.add(atype) + db.session.commit() + asset = Asset(assetnumber='AST-PANEL01', assettypeid=atype.assettypeid, + isactive=True) + db.session.add(asset) + db.session.commit() + return asset.assetid + + +def test_asset_panels_require_assetid(client, auth_headers): + """asset-panels without assetid is a 400.""" + response = client.get('/api/pluginui/asset-panels', headers=auth_headers) + assert response.status_code == 400 + + +def test_asset_panels_match_asset_type(app, client, db, auth_headers, monkeypatch): + """Warranty's ['*'] panel matches any asset; tagged with its plugin.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + assetid = _make_asset(db) + + panels = _data(client, f'/api/pluginui/asset-panels?assetid={assetid}', auth_headers) + warranty = next((p for p in panels if p.get('plugin') == 'warranty'), None) + assert warranty is not None, 'warranty asset panel missing' + assert warranty['id'] == 'warranty' + assert warranty['endpoint'] == '/api/warranty/asset/{assetid}' + assert warranty['render'] in ('keyvalue', 'table', 'badge') + + +def test_asset_panels_skip_disabled_plugin(app, client, db, auth_headers, monkeypatch): + """A disabled warranty drops its asset panel.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'warranty') + assetid = _make_asset(db) + + panels = _data(client, f'/api/pluginui/asset-panels?assetid={assetid}', auth_headers) + assert 'warranty' not in {p.get('plugin') for p in panels} + + +# ============================================================================= +# map-overlays +# ============================================================================= + +def test_map_overlays_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch): + """measuringtools contributes its calibration-due overlay.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + overlays = _data(client, '/api/pluginui/map-overlays', auth_headers) + overlay = next((o for o in overlays if o.get('plugin') == 'measuringtools'), None) + assert overlay is not None + assert overlay['id'] == 'calibration-due' + assert overlay['legend'] is True + + +def test_map_overlays_skip_disabled_plugin(app, client, auth_headers, monkeypatch): + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools') + + overlays = _data(client, '/api/pluginui/map-overlays', auth_headers) + assert 'measuringtools' not in {o.get('plugin') for o in overlays} + + +# ============================================================================= +# asset-presentation +# ============================================================================= + +def test_asset_presentation_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch): + """measuringtools declares presentation for its measuring_tool type.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + entries = _data(client, '/api/pluginui/asset-presentation', auth_headers) + entry = next((e for e in entries if e.get('assettype') == 'measuring_tool'), None) + assert entry is not None + assert entry['plugin'] == 'measuringtools' + assert entry['route'] == '/measuringtools/by-asset/{assetid}' + + +def test_asset_presentation_skip_disabled_plugin(app, client, auth_headers, monkeypatch): + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools') + + entries = _data(client, '/api/pluginui/asset-presentation', auth_headers) + assert 'measuring_tool' not in {e.get('assettype') for e in entries} diff --git a/tests/test_core/test_relationship_directional.py b/tests/test_core/test_relationship_directional.py new file mode 100644 index 00000000..ce59deb6 --- /dev/null +++ b/tests/test_core/test_relationship_directional.py @@ -0,0 +1,106 @@ +"""Relationship-type directionality: CRUD carries isdirectional and the +per-asset relationships endpoint tags each row with its type directionality. + +The frontend uses isdirectional to collapse symmetric-type rows (Dualpath and +other physical links) into one direction-blind card entry, and to render +directional types (controls, partof...) with source->target phrasing. That +dedupe is view logic verified live; here we lock the backend surface it needs. +""" + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.relationship import RelationshipType, AssetRelationship + + +def _make_asset(assetnumber, typeid): + asset = Asset(assetnumber=assetnumber, assettypeid=typeid) + db.session.add(asset) + db.session.flush() + return asset + + +# --------------------------------------------------------------------------- +# Relationship type CRUD carries isdirectional +# --------------------------------------------------------------------------- + +def test_create_type_defaults_directional(client, db, auth_headers): + resp = client.post('/api/assets/relationshiptypes', + json={'relationshiptype': 'Backup For'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['isdirectional'] is True + + +def test_create_symmetric_type(client, db, auth_headers): + resp = client.post('/api/assets/relationshiptypes', + json={'relationshiptype': 'Dualpath', + 'isdirectional': False}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['isdirectional'] is False + + +def test_update_toggles_directional(client, db, auth_headers): + typeid = client.post('/api/assets/relationshiptypes', + json={'relationshiptype': 'USB'}, + headers=auth_headers).get_json()['data']['relationshiptypeid'] + resp = client.put(f'/api/assets/relationshiptypes/{typeid}', + json={'isdirectional': False}, headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['isdirectional'] is False + + +def test_list_includes_directional(client, db, auth_headers): + client.post('/api/assets/relationshiptypes', + json={'relationshiptype': 'WiFi', 'isdirectional': False}, + headers=auth_headers) + resp = client.get('/api/assets/relationshiptypes') + assert resp.status_code == 200 + row = next(t for t in resp.get_json()['data'] + if t['relationshiptype'] == 'WiFi') + assert row['isdirectional'] is False + + +# --------------------------------------------------------------------------- +# Per-asset relationships endpoint tags each row with isdirectional +# --------------------------------------------------------------------------- + +def test_relationships_row_carries_isdirectional(client, db, auth_headers): + atype = AssetType(assettype='machine') + db.session.add(atype) + db.session.flush() + + symmetric = RelationshipType(relationshiptype='Dualpath', isdirectional=False) + directional = RelationshipType(relationshiptype='controls', isdirectional=True) + db.session.add_all([symmetric, directional]) + db.session.flush() + + a = _make_asset('M-A', atype.assettypeid) + b = _make_asset('M-B', atype.assettypeid) + + # symmetric link stored in both directions (legacy shape) + db.session.add(AssetRelationship(sourceassetid=a.assetid, targetassetid=b.assetid, + relationshiptypeid=symmetric.relationshiptypeid)) + db.session.add(AssetRelationship(sourceassetid=b.assetid, targetassetid=a.assetid, + relationshiptypeid=symmetric.relationshiptypeid)) + # directional link a controls b + db.session.add(AssetRelationship(sourceassetid=a.assetid, targetassetid=b.assetid, + relationshiptypeid=directional.relationshiptypeid)) + db.session.commit() + + resp = client.get(f'/api/assets/{a.assetid}/relationships') + assert resp.status_code == 200 + data = resp.get_json()['data'] + + for row in data['outgoing']: + assert 'isdirectional' in row + for row in data['incoming']: + assert 'isdirectional' in row + + # symmetric type reports False both directions; directional reports True + sym_rows = [r for r in data['outgoing'] + data['incoming'] + if r['relationshiptypename'] == 'Dualpath'] + assert sym_rows and all(r['isdirectional'] is False for r in sym_rows) + ctrl_rows = [r for r in data['outgoing'] + if r['relationshiptypename'] == 'controls'] + assert ctrl_rows and all(r['isdirectional'] is True for r in ctrl_rows) diff --git a/tests/test_core/test_relationship_propagation.py b/tests/test_core/test_relationship_propagation.py new file mode 100644 index 00000000..c9611cbe --- /dev/null +++ b/tests/test_core/test_relationship_propagation.py @@ -0,0 +1,295 @@ +"""Relationship propagation across symmetric rails (ADR-001, wired). + +A Dualpath pair is one physical dual-bay machine with a single controller PC +and a bay-selector switch, so BOTH bay records must carry the controls link to +that PC. Creating PC controls bayA where bayA has a Dualpath partner bayB +auto-creates PC controls bayB. The propagation set is M:N +(relationshiptypepropagations): controls propagates through Dualpath +(symmetric, consumed) and through partof (directional, declared but inert). +""" + +from shopdb.extensions import db +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.relationship import ( + RelationshipType, + RelationshipTypePropagation, + AssetRelationship, +) + + +def _make_asset(assetnumber, typeid): + asset = Asset(assetnumber=assetnumber, assettypeid=typeid) + db.session.add(asset) + db.session.flush() + return asset + + +def _setup_types(): + atype = AssetType(assettype='machine') + db.session.add(atype) + db.session.flush() + + controls = RelationshipType(relationshiptype='controls', isdirectional=True) + dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False) + partof = RelationshipType(relationshiptype='partof', isdirectional=True) + db.session.add_all([controls, dualpath, partof]) + db.session.flush() + return atype, controls, dualpath, partof + + +def _rel_exists(sourceid, targetid, typeid): + return AssetRelationship.query.filter_by( + sourceassetid=sourceid, targetassetid=targetid, relationshiptypeid=typeid + ).first() is not None + + +# --------------------------------------------------------------------------- +# Symmetric through-type propagates (controls fans across a Dualpath pair) +# --------------------------------------------------------------------------- + +def test_controls_propagates_across_dualpath_pair(client, db, auth_headers): + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-1', atype.assettypeid) + baya = _make_asset('BAY-A', atype.assettypeid) + bayb = _make_asset('BAY-B', atype.assettypeid) + + # the two bays are Dualpath partners (one physical machine) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': baya.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['propagatedcount'] == 1 + + # PC now controls BOTH bays + assert _rel_exists(pc.assetid, baya.assetid, controls.relationshiptypeid) + assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + +def test_propagation_finds_partner_stored_either_direction(client, db, auth_headers): + # Dualpath row stored partner->target; propagation must still catch it. + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-2', atype.assettypeid) + baya = _make_asset('BAY-A2', atype.assettypeid) + bayb = _make_asset('BAY-B2', atype.assettypeid) + # note: bayb is the SOURCE of the Dualpath row, target is baya + db.session.add(AssetRelationship(sourceassetid=bayb.assetid, targetassetid=baya.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': baya.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201 + assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + +def test_controls_propagates_when_bay_is_source(client, db, auth_headers): + # Matches real dev data: controls is stored bay -> PC (source is the bay). + # The bay's Dualpath partner must also get a controls -> PC row. + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-S', atype.assettypeid) + baya = _make_asset('BAY-AS', atype.assettypeid) + bayb = _make_asset('BAY-BS', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': baya.assetid, + 'targetassetid': pc.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['propagatedcount'] == 1 + # partner bay now also controls the PC, same direction (bay -> PC) + assert _rel_exists(bayb.assetid, pc.assetid, controls.relationshiptypeid) + + +# --------------------------------------------------------------------------- +# Idempotent: pre-existing propagated row is not duplicated +# --------------------------------------------------------------------------- + +def test_propagation_idempotent(client, db, auth_headers): + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-3', atype.assettypeid) + baya = _make_asset('BAY-A3', atype.assettypeid) + bayb = _make_asset('BAY-B3', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + # the mirrored row already exists + db.session.add(AssetRelationship(sourceassetid=pc.assetid, targetassetid=bayb.assetid, + relationshiptypeid=controls.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': baya.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201 + assert resp.get_json()['data']['propagatedcount'] == 0 + + count = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, targetassetid=bayb.assetid, + relationshiptypeid=controls.relationshiptypeid).count() + assert count == 1 + + +# --------------------------------------------------------------------------- +# Non-propagating type creates no extras +# --------------------------------------------------------------------------- + +def test_non_propagating_type_no_extras(client, db, auth_headers): + atype, controls, dualpath, partof = _setup_types() + # controls has NO propagation rows here + + pc = _make_asset('PC-4', atype.assettypeid) + baya = _make_asset('BAY-A4', atype.assettypeid) + bayb = _make_asset('BAY-B4', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': baya.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201 + assert resp.get_json()['data']['propagatedcount'] == 0 + assert not _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + +# --------------------------------------------------------------------------- +# Directional through-type (partof) does NOT propagate (deferred) +# --------------------------------------------------------------------------- + +def test_directional_through_type_does_not_propagate(client, db, auth_headers): + atype, controls, dualpath, partof = _setup_types() + # controls propagates only through partof, which is directional -> inert + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=partof.relationshiptypeid)) + + pc = _make_asset('PC-5', atype.assettypeid) + parent = _make_asset('PARENT-5', atype.assettypeid) + child = _make_asset('CHILD-5', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=child.assetid, targetassetid=parent.assetid, + relationshiptypeid=partof.relationshiptypeid)) + db.session.commit() + + resp = client.post('/api/assets/relationships', headers=auth_headers, json={ + 'sourceassetid': pc.assetid, + 'targetassetid': parent.assetid, + 'relationshiptypeid': controls.relationshiptypeid, + }) + assert resp.status_code == 201 + assert resp.get_json()['data']['propagatedcount'] == 0 + assert not _rel_exists(pc.assetid, child.assetid, controls.relationshiptypeid) + + +# --------------------------------------------------------------------------- +# CLI backfill creates missing rows and is idempotent run twice +# --------------------------------------------------------------------------- + +def test_cli_backfill_propagates_and_is_idempotent(client, db, auth_headers, runner): + atype, controls, dualpath, partof = _setup_types() + db.session.add(RelationshipTypePropagation( + relationshiptypeid=controls.relationshiptypeid, + throughtypeid=dualpath.relationshiptypeid)) + + pc = _make_asset('PC-6', atype.assettypeid) + baya = _make_asset('BAY-A6', atype.assettypeid) + bayb = _make_asset('BAY-B6', atype.assettypeid) + db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid, + relationshiptypeid=dualpath.relationshiptypeid)) + # legacy-import shape: controls exists only on the primary bay + db.session.add(AssetRelationship(sourceassetid=pc.assetid, targetassetid=baya.assetid, + relationshiptypeid=controls.relationshiptypeid)) + db.session.commit() + + assert not _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + result = runner.invoke(args=['relationships', 'propagate']) + assert result.exit_code == 0, result.output + assert 'Propagated 1' in result.output + assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid) + + # second run creates nothing more + result2 = runner.invoke(args=['relationships', 'propagate']) + assert result2.exit_code == 0, result2.output + assert 'Propagated 0' in result2.output + count = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, targetassetid=bayb.assetid, + relationshiptypeid=controls.relationshiptypeid).count() + assert count == 1 + + +# --------------------------------------------------------------------------- +# CLI fix-controls-direction flips reversed legacy rows to PC -> machine +# --------------------------------------------------------------------------- + +def test_cli_fix_controls_direction(client, db, auth_headers, runner): + atype, controls, dualpath, partof = _setup_types() + ctype = AssetType(assettype='computer') + db.session.add(ctype) + db.session.flush() + + pc = _make_asset('PC-7', ctype.assettypeid) + pc2 = _make_asset('PC-8', ctype.assettypeid) + bay = _make_asset('BAY-7', atype.assettypeid) + bay2 = _make_asset('BAY-8', atype.assettypeid) + + # reversed legacy row: machine -> PC + db.session.add(AssetRelationship(sourceassetid=bay.assetid, targetassetid=pc.assetid, + relationshiptypeid=controls.relationshiptypeid)) + # reversed row whose flip already exists -> reversed one gets deactivated + db.session.add(AssetRelationship(sourceassetid=bay2.assetid, targetassetid=pc2.assetid, + relationshiptypeid=controls.relationshiptypeid)) + db.session.add(AssetRelationship(sourceassetid=pc2.assetid, targetassetid=bay2.assetid, + relationshiptypeid=controls.relationshiptypeid)) + db.session.commit() + + result = runner.invoke(args=['relationships', 'fix-controls-direction']) + assert result.exit_code == 0, result.output + assert 'Flipped 1' in result.output + assert '1 reversed duplicate(s) deactivated' in result.output + + # first row now reads PC -> machine + assert _rel_exists(pc.assetid, bay.assetid, controls.relationshiptypeid) + assert not _rel_exists(bay.assetid, pc.assetid, controls.relationshiptypeid) + # duplicate pair: canonical row stays active, reversed one deactivated + rev = AssetRelationship.query.filter_by( + sourceassetid=bay2.assetid, targetassetid=pc2.assetid, + relationshiptypeid=controls.relationshiptypeid).first() + assert rev is not None and rev.isactive is False + + # idempotent: second run finds nothing + result2 = runner.invoke(args=['relationships', 'fix-controls-direction']) + assert result2.exit_code == 0, result2.output + assert 'Flipped 0' in result2.output + assert '0 reversed duplicate(s) deactivated' in result2.output diff --git a/tests/test_core/test_reports_hook.py b/tests/test_core/test_reports_hook.py new file mode 100644 index 00000000..b2c4ab2d --- /dev/null +++ b/tests/test_core/test_reports_hook.py @@ -0,0 +1,55 @@ +"""Tests for the get_reports hook consumer (GET /api/reports). + +Pins the wiring added for BasePlugin.get_reports: the reports list merges +enabled plugins' report cards after the static core reports and skips disabled +ones. Also guards the removed legacy warranty-status core report. + +Plugin enabled-state is monkeypatched (not persisted) so these tests do not +mutate the shared instance/plugins.json registry file. +""" + + +def _report_ids(client, headers): + response = client.get('/api/reports', headers=headers) + assert response.status_code == 200, response.get_json() + reports = response.get_json()['data']['reports'] + assert isinstance(reports, list) + return reports, {r['id'] for r in reports} + + +def test_reports_include_core_reports(client, auth_headers): + """The static core reports are always listed.""" + _, ids = _report_ids(client, auth_headers) + assert 'machines-by-type' in ids + assert 'pc-relationships' in ids + + +def test_legacy_warranty_status_report_absent(client, auth_headers): + """The dead warranty-status core report was removed.""" + _, ids = _report_ids(client, auth_headers) + assert 'warranty-status' not in ids + + +def test_enabled_plugin_reports_are_merged(app, client, auth_headers, monkeypatch): + """Enabled plugins implementing get_reports contribute their cards.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + + reports, ids = _report_ids(client, auth_headers) + # warranty + printers implement get_reports + assert 'warranty' in ids + assert 'toner' in ids + # merged cards carry their originating plugin name + warranty_card = next(r for r in reports if r['id'] == 'warranty') + assert warranty_card['plugin'] == 'warranty' + assert warranty_card['route'] == '/reports/warranty' + + +def test_disabled_plugin_reports_drop_out(app, client, auth_headers, monkeypatch): + """A disabled plugin's report cards disappear from the list.""" + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'warranty') + + _, ids = _report_ids(client, auth_headers) + assert 'warranty' not in ids + assert 'toner' in ids # printers still enabled diff --git a/tests/test_core/test_search_customfields.py b/tests/test_core/test_search_customfields.py new file mode 100644 index 00000000..24e3909e --- /dev/null +++ b/tests/test_core/test_search_customfields.py @@ -0,0 +1,114 @@ +"""Global search matches custom-field VALUES for fields flagged searchable. + +Pins: a searchable custom field's value routes to its asset; a non-searchable +field is NOT matched; disabling the asset's search domain excludes the hit; an +asset matched by BOTH its assetnumber and a custom field appears once; an +inactive field is excluded. + +Search by a distinctive value string so only the custom-field domain can match +(the value is not any built-in identifier). +""" + +from shopdb.core.api.settings import invalidate_settings_cache + + +def _seed_computer(client, db, auth_headers, assetnumber): + """Create a computer asset and return its assetid.""" + from shopdb.core.models import AssetType, Asset + if not AssetType.query.filter_by(assettype='computer').first(): + db.session.add(AssetType(assettype='computer', pluginname='computer', + tablename='computer', description='c')) + db.session.commit() + resp = client.post('/api/computers', + json={'assetnumber': assetnumber}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + asset = Asset.query.filter_by(assetnumber=assetnumber).first() + return asset.assetid + + +def _seed_field(db, label, searchable=True, isactive=True): + """Create a computer-scoped custom field and return its fieldid.""" + from shopdb.core.models import AssetType, CustomField + assettype = AssetType.query.filter_by(assettype='computer').first() + field = CustomField(assettypeid=assettype.assettypeid, + fieldkey=label.replace(' ', '').lower(), + label=label, datatype='text', + searchable=searchable, isactive=isactive) + db.session.add(field) + db.session.commit() + return field.fieldid + + +def _set_value(db, fieldid, assetid, value): + from shopdb.core.models import CustomFieldValue + db.session.add(CustomFieldValue(fieldid=fieldid, assetid=assetid, value=value)) + db.session.commit() + + +def _hits(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + return resp.get_json()['data']['results'] + + +def test_searchable_field_value_matches_and_routes(client, db, auth_headers): + """A searchable custom-field value matches and routes to the asset page.""" + assetid = _seed_computer(client, db, auth_headers, 'AST-CF01') + fieldid = _seed_field(db, 'Tool Crib Number', searchable=True) + _set_value(db, fieldid, assetid, 'cribval01') + + hits = [r for r in _hits(client, auth_headers, 'cribval01') + if r['type'] == 'computer'] + assert len(hits) == 1 + assert hits[0]['url'].startswith('/pcs/') + assert hits[0]['subtitle'] == 'Tool Crib Number' + + +def test_non_searchable_field_value_does_not_match(client, db, auth_headers): + """A value on a NON-searchable field is not matched by global search.""" + assetid = _seed_computer(client, db, auth_headers, 'AST-CF02') + fieldid = _seed_field(db, 'Internal Note', searchable=False) + _set_value(db, fieldid, assetid, 'cribval02') + + assert _hits(client, auth_headers, 'cribval02') == [] + + +def test_disabled_domain_excludes_customfield_hit(client, db, auth_headers): + """search_computer_enabled=false hides a custom-field hit on a computer.""" + from shopdb.core.models import Setting + assetid = _seed_computer(client, db, auth_headers, 'AST-CF03') + fieldid = _seed_field(db, 'Bench Tag', searchable=True) + _set_value(db, fieldid, assetid, 'cribval03') + + # Sanity: enabled by default. + assert [r for r in _hits(client, auth_headers, 'cribval03') + if r['type'] == 'computer'] + + Setting.set('search_computer_enabled', False, valuetype='boolean', + category='search') + invalidate_settings_cache() + assert [r for r in _hits(client, auth_headers, 'cribval03') + if r['type'] == 'computer'] == [] + + +def test_dedupe_assetnumber_and_customfield(client, db, auth_headers): + """An asset matched by both assetnumber and a custom field appears once.""" + assetid = _seed_computer(client, db, auth_headers, 'cribval04') + fieldid = _seed_field(db, 'Crib Ref', searchable=True) + _set_value(db, fieldid, assetid, 'cribval04') + + hits = [r for r in _hits(client, auth_headers, 'cribval04') + if r['type'] == 'computer'] + assert len(hits) == 1 + # The higher-relevance built-in assetnumber match wins the dedupe. + assert hits[0]['relevance'] == 100 + + +def test_inactive_field_excluded(client, db, auth_headers): + """A value on an inactive (searchable) field is not matched.""" + assetid = _seed_computer(client, db, auth_headers, 'AST-CF05') + fieldid = _seed_field(db, 'Retired Tag', searchable=True, isactive=False) + _set_value(db, fieldid, assetid, 'cribval05') + + assert _hits(client, auth_headers, 'cribval05') == [] diff --git a/tests/test_core/test_search_disabled.py b/tests/test_core/test_search_disabled.py new file mode 100644 index 00000000..fea12d9e --- /dev/null +++ b/tests/test_core/test_search_disabled.py @@ -0,0 +1,43 @@ +"""Global search must exclude rows from disabled plugins (search.py _require_enabled). + +Pins the data-exposure property: disabling a plugin removes its plugin-specific +matches (e.g. hostname) from /api/search, mirroring the plugin being absent. + +Search by HOSTNAME (distinct from assetnumber) so only the gated hostname domain +can match - the core asset-number domain is intentionally always-on. +""" + + +def _seed_computer(client, db, auth_headers, assetnumber, hostname): + from shopdb.core.models import AssetType + if not AssetType.query.filter_by(assettype='computer').first(): + db.session.add(AssetType(assettype='computer', pluginname='computer', + tablename='computer', description='c')) + db.session.commit() + resp = client.post('/api/computers', + json={'assetnumber': assetnumber, 'hostname': hostname}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + + +def _computer_hits(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + results = resp.get_json()['data']['results'] + return [r for r in results if r.get('type') == 'computer'] + + +def test_search_includes_enabled_plugin(app, client, db, auth_headers, monkeypatch): + """An enabled plugin's hostname matches appear in search.""" + _seed_computer(client, db, auth_headers, 'AST-SD01', 'hostsearch01') + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + assert _computer_hits(client, auth_headers, 'hostsearch01') + + +def test_search_excludes_disabled_plugin(app, client, db, auth_headers, monkeypatch): + """A disabled plugin's hostname matches drop out of search results.""" + _seed_computer(client, db, auth_headers, 'AST-SD02', 'hostsearch02') + pm = app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'computers') + assert _computer_hits(client, auth_headers, 'hostsearch02') == [] diff --git a/tests/test_core/test_search_integrations.py b/tests/test_core/test_search_integrations.py new file mode 100644 index 00000000..4da87cc2 --- /dev/null +++ b/tests/test_core/test_search_integrations.py @@ -0,0 +1,105 @@ +"""Settings-driven search integrations (ServiceNow + employee-ID pattern). + +The ServiceNow ticket prefixes, the ServiceNow search URL/enabled flag, and the +employee-ID recognition pattern all ship as GE defaults but are configurable +per-site via Settings. These tests pin that the search endpoint honors those +settings and that a bad employeeid_pattern regex falls back rather than 500ing. + +Settings are cached, so every case seeds its rows and calls +invalidate_settings_cache() before exercising search. +""" + +from shopdb.core.models import Setting +from shopdb.core.api.settings import invalidate_settings_cache +from shopdb.core.api.search import _get_search_integrations, _classify_query + + +def _redirect(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + return resp.get_json()['data'].get('redirect') + + +def test_default_prefix_classifies_servicenow(client, db, auth_headers): + """With no override, a GE-prefixed ticket redirects to ServiceNow.""" + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'GEINC123') + assert redirect is not None + assert redirect['type'] == 'servicenow' + + +def test_custom_prefix_classifies_servicenow(client, db, auth_headers): + """A site-configured prefix classifies its tickets as ServiceNow.""" + Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string', + category='integrations') + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'ACMEINC123') + assert redirect is not None + assert redirect['type'] == 'servicenow' + # Ticket is url-encoded into the {ticket} placeholder. + assert 'ACMEINC123' in redirect['url'] + + +def test_custom_prefix_ignores_default_prefix(client, db, auth_headers): + """Overriding the prefixes drops the built-in GE prefixes.""" + Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string', + category='integrations') + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'GEINC123') + # GEINC is no longer a configured prefix, so no ServiceNow redirect. + assert redirect is None or redirect.get('type') != 'servicenow' + + +def test_disabled_servicenow_yields_no_redirect(client, db, auth_headers): + """servicenow_enabled=false suppresses the ServiceNow redirect entirely.""" + Setting.set('servicenow_enabled', False, valuetype='boolean', + category='integrations') + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'GEINC123') + assert redirect is None or redirect.get('type') != 'servicenow' + + +def test_blank_search_url_yields_no_redirect(client, db, auth_headers): + """An empty servicenow_search_url disables the redirect.""" + Setting.set('servicenow_search_url', '', valuetype='string', + category='integrations') + invalidate_settings_cache() + redirect = _redirect(client, auth_headers, 'GEINC123') + assert redirect is None or redirect.get('type') != 'servicenow' + + +def test_custom_employeeid_pattern_recognizes_six_digits(client, db, auth_headers): + """A ^\\d{6}$ pattern makes six-digit IDs classify as employee queries.""" + Setting.set('employeeid_pattern', r'^\d{6}$', valuetype='string', + category='site') + invalidate_settings_cache() + integrations = _get_search_integrations() + assert _classify_query('123456', integrations)['is_sso'] is True + # Anchored pattern rejects the old nine-digit shape. + assert _classify_query('123456789', integrations)['is_sso'] is False + + +def test_default_employeeid_pattern_recognizes_nine_digits(client, db, auth_headers): + """The shipped default recognizes nine-digit SSO IDs.""" + invalidate_settings_cache() + integrations = _get_search_integrations() + assert _classify_query('123456789', integrations)['is_sso'] is True + assert _classify_query('123456', integrations)['is_sso'] is False + + +def test_invalid_employeeid_pattern_falls_back(client, db, auth_headers): + """A malformed regex falls back to nine-digit matching without erroring.""" + Setting.set('employeeid_pattern', '[', valuetype='string', category='site') + invalidate_settings_cache() + # Must not raise on compile of the bad pattern. + integrations = _get_search_integrations() + assert _classify_query('123456789', integrations)['is_sso'] is True + assert _classify_query('12345', integrations)['is_sso'] is False + + +def test_invalid_pattern_search_does_not_500(client, db, auth_headers): + """End-to-end: a bad employeeid_pattern must not break the search endpoint.""" + Setting.set('employeeid_pattern', '(', valuetype='string', category='site') + invalidate_settings_cache() + resp = client.get('/api/search?q=hello', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() diff --git a/tests/test_core/test_search_settings.py b/tests/test_core/test_search_settings.py new file mode 100644 index 00000000..2514ab28 --- /dev/null +++ b/tests/test_core/test_search_settings.py @@ -0,0 +1,44 @@ +"""Per-domain global-search toggles (search__enabled). + +A content type can be hidden from search independently of whether its plugin is +enabled, by setting search__enabled = false. +""" + +from shopdb.core.api.settings import SEARCH_DOMAINS + + +def _kb_types(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + return [r for r in resp.get_json()['data']['results'] + if r.get('type') == 'knowledgebase'] + + +def test_domain_included_by_default(client, db, auth_headers): + """With no toggle set, the knowledgebase domain appears in search.""" + client.post('/api/knowledgebase', + json={'shortdescription': 'searchtoggle alpha', + 'linkurl': 'https://kb.example/a'}, + headers=auth_headers) + assert _kb_types(client, auth_headers, 'searchtoggle') + + +def test_disabled_domain_excluded(client, db, auth_headers): + """search_knowledgebase_enabled=false hides KB results.""" + from shopdb.core.models import Setting + client.post('/api/knowledgebase', + json={'shortdescription': 'searchtoggle beta', + 'linkurl': 'https://kb.example/b'}, + headers=auth_headers) + Setting.set('search_knowledgebase_enabled', False, valuetype='boolean', + category='search') + assert _kb_types(client, auth_headers, 'searchtoggle') == [] + + +def test_search_seed_creates_domain_keys(client, db, auth_headers): + """Seeding produces one boolean setting per search domain.""" + resp = client.post('/api/settings/seed', headers=auth_headers) + assert resp.status_code == 200 + from shopdb.core.models import Setting + keys = {s.key for s in Setting.query.filter_by(category='search').all()} + assert {f'search_{k}_enabled' for k in SEARCH_DOMAINS} <= keys diff --git a/tests/test_core/test_setting_model.py b/tests/test_core/test_setting_model.py new file mode 100644 index 00000000..4c53e3e5 --- /dev/null +++ b/tests/test_core/test_setting_model.py @@ -0,0 +1,56 @@ +"""Tests for the Setting key-value model, incl. the create-race recovery.""" + +from sqlalchemy.exc import IntegrityError + +from shopdb.core.models import Setting +from shopdb.extensions import db as _db + + +def test_set_creates_then_updates(db): + """set() creates a missing key, then updates the existing row in place.""" + created = Setting.set('greeting', 'hello', category='site') + assert created.settingid is not None + assert Setting.get('greeting') == 'hello' + + Setting.set('greeting', 'goodbye') + assert Setting.get('greeting') == 'goodbye' + + # Only one row for the key (update, not a second insert). + assert Setting.query.filter_by(key='greeting').count() == 1 + + +def test_set_boolean_roundtrip(db): + """Booleans store as canonical strings and read back typed.""" + Setting.set('flag', True, valuetype='boolean') + assert Setting.get('flag') is True + Setting.set('flag', False) + assert Setting.get('flag') is False + + +def test_set_recovers_from_create_race(db, monkeypatch): + """A concurrent insert of the same key makes our commit raise IntegrityError; + set() rolls back, re-fetches the winner's row, and applies our value.""" + real_commit = _db.session.commit + calls = {'n': 0} + + def flaky_commit(): + calls['n'] += 1 + if calls['n'] == 1: + # Drop our own pending insert, then persist the "winner" row exactly + # as a racing transaction would have, and simulate the unique-key + # violation our losing INSERT would raise. + _db.session.rollback() + winner = Setting(key='racekey', value='winner', valuetype='string') + _db.session.add(winner) + real_commit() + raise IntegrityError('INSERT INTO settings', {}, Exception('duplicate key')) + return real_commit() + + monkeypatch.setattr(_db.session, 'commit', flaky_commit) + + result = Setting.set('racekey', 'mine') + + # Recovery path ran: the row exists once and carries our value. + assert result is not None + assert Setting.get('racekey') == 'mine' + assert Setting.query.filter_by(key='racekey').count() == 1 diff --git a/tests/test_core/test_settings_branding.py b/tests/test_core/test_settings_branding.py new file mode 100644 index 00000000..274f1f1f --- /dev/null +++ b/tests/test_core/test_settings_branding.py @@ -0,0 +1,192 @@ +"""Tests for branding-logo upload and settings-secret masking. + +Covers WP1 of the multi-site distribution work: the branding-logo upload +endpoint (admin-only, kind/extension validation, round-trip), the public +serve route, the secret-masking guarantee on the settings list, and the +canonical new-settings defaults. +""" + +import io + +from shopdb.core.api.settings import ( + build_default_settings, + BRANDING_KIND_SETTINGS, +) + + +def _defaults_by_key(): + return {d['key']: d for d in build_default_settings()} + + +def test_anon_settings_list_excludes_nonpublic(client, db): + """Anonymous GET /api/settings returns only the public allowlist - secret + AND non-secret internal keys are absent, so plaintext can never leak.""" + from shopdb.core.models import Setting + + Setting.set('smtp_password', 'supersecret', valuetype='string', category='email') + Setting.set('zabbix_token', 'tok-abc-123', valuetype='string', category='integrations') + Setting.set('warranty_dell_clientsecret', 'shh', valuetype='string', category='integrations') + # A public key stays visible for contrast. + Setting.set('facility_name', 'Test Plant', valuetype='string', category='site') + + resp = client.get('/api/settings') + assert resp.status_code == 200, resp.get_json() + by_key = {s['key']: s['value'] for s in resp.get_json()['data']} + + # Non-public keys are not enumerable at all (stronger than masking). + assert 'smtp_password' not in by_key + assert 'zabbix_token' not in by_key + assert 'warranty_dell_clientsecret' not in by_key + # Plaintext secrets must not leak anywhere in the response body. + assert 'supersecret' not in resp.get_data(as_text=True) + assert 'tok-abc-123' not in resp.get_data(as_text=True) + # A public key passes through untouched. + assert by_key['facility_name'] == 'Test Plant' + + +def test_branding_upload_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot upload a branding logo.""" + data = { + 'kind': 'site', + 'file': (io.BytesIO(b''), 'logo.svg'), + } + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=member_headers) + assert resp.status_code == 403 + assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_branding_upload_requires_auth(client, db): + """No token at all cannot reach the upload route.""" + data = {'kind': 'site', 'file': (io.BytesIO(b''), 'logo.svg')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data') + assert resp.status_code in (401, 422) + + +def test_branding_upload_round_trip(client, db, auth_headers): + """Admin upload writes the matching setting and the file is then served.""" + payload = b'' + data = {'kind': 'site', 'file': (io.BytesIO(payload), 'mylogo.svg')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + body = resp.get_json()['data'] + assert body['key'] == 'site_logo' + assert body['value'] == '/api/settings/branding/logo-site.svg' + + # Setting row now points at the served URL. + from shopdb.core.models import Setting + setting = Setting.query.filter_by(key='site_logo').first() + assert setting is not None + assert setting.value == '/api/settings/branding/logo-site.svg' + + # Public serve route returns the uploaded bytes without auth. + served = client.get('/api/settings/branding/logo-site.svg') + assert served.status_code == 200 + assert served.get_data() == payload + + +def test_branding_upload_favicon_allows_ico(client, db, auth_headers): + """The favicon kind accepts .ico (extra extension beyond map images).""" + data = {'kind': 'favicon', 'file': (io.BytesIO(b'icodata'), 'fav.ico')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['key'] == 'site_favicon' + + +def test_branding_upload_rejects_invalid_kind(client, db, auth_headers): + """An unknown kind is a validation error.""" + data = {'kind': 'banner', 'file': (io.BytesIO(b''), 'logo.svg')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR' + + +def test_branding_upload_rejects_invalid_extension(client, db, auth_headers): + """A disallowed file extension is rejected.""" + data = {'kind': 'site', 'file': (io.BytesIO(b'MZ...'), 'logo.exe')} + resp = client.post('/api/settings/branding-logo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR' + + +def test_kind_map_covers_expected_kinds(): + """The kind->setting map matches the documented branding kinds.""" + assert BRANDING_KIND_SETTINGS == { + 'site': 'site_logo', + 'qr': 'qr_logo', + 'badge': 'badge_logo', + 'favicon': 'site_favicon', + } + + +def test_defaults_contain_new_branding_keys(): + """build_default_settings seeds every branding key with its default.""" + by_key = _defaults_by_key() + expected = { + 'site_logo': '/ge-aerospace-logo.svg', + 'qr_logo': '/ge-monogram.svg', + 'badge_logo': '/ge-aerospace-logo.svg', + 'site_favicon': '', + 'brand_primary_color': '', + } + for key, value in expected.items(): + assert key in by_key, f'missing default {key}' + assert by_key[key]['value'] == value + assert by_key[key]['category'] == 'branding' + + +def test_defaults_contain_new_integration_keys(): + """build_default_settings seeds the ServiceNow integration keys.""" + by_key = _defaults_by_key() + assert by_key['servicenow_enabled']['value'] == 'true' + assert by_key['servicenow_enabled']['category'] == 'integrations' + assert by_key['servicenow_ticket_prefixes']['value'] == 'GEINC,GECHG,GERIT,GESCT' + assert '{ticket}' in by_key['servicenow_search_url']['value'] + assert '{ticket}' in by_key['servicenow_incident_url']['value'] + assert '{ticket}' in by_key['servicenow_change_url']['value'] + + +def test_defaults_contain_new_site_keys(): + """build_default_settings seeds the employee-id and printer-hostname keys.""" + by_key = _defaults_by_key() + assert by_key['employeeid_pattern']['value'] == r'^\d{9}$' + assert by_key['printer_hostname_template']['value'] == 'Printer-{ip}.printer.geaerospace.net' + assert by_key['contact_email_domain']['value'] == 'geaerospace.com' + assert by_key['contact_email_domain']['category'] == 'site' + + +def test_defaults_contain_label_target_keys(): + """build_default_settings seeds the per-type label targets + label defaults.""" + by_key = _defaults_by_key() + for key in ('qr_target_machine', 'qr_target_computer', + 'qr_target_network_device', 'qr_target_measuring_tool'): + assert key in by_key, f'missing default {key}' + assert by_key[key]['value'] == '' + assert by_key[key]['category'] == 'printing' + assert '{locationcode}' in by_key['qr_target_measuring_tool']['description'] + assert by_key['label_default_style']['value'] == 'card' + assert by_key['label_default_codetype']['value'] == 'qr' + assert by_key['label_default_style']['category'] == 'printing' + assert by_key['label_default_codetype']['category'] == 'printing' + # Per-type default encode mode: machines -> their machine number, + # measuring tools -> inspection location, the rest -> asset page. + assert by_key['label_default_encodes_machine']['value'] == 'assetnumber' + assert by_key['label_default_encodes_measuring_tool']['value'] == 'location' + for assettype in ('computer', 'printer', 'network_device'): + assert by_key[f'label_default_encodes_{assettype}']['value'] == 'assetpage' + assert by_key[f'label_default_encodes_{assettype}']['category'] == 'printing' + + +def test_defaults_changed_facility_and_map(): + """facility_name default is now blank; map blueprints point at the placeholder.""" + by_key = _defaults_by_key() + assert by_key['facility_name']['value'] == '' + assert by_key['map_blueprint_light']['value'] == '/static/images/floorplan-placeholder.svg' + assert by_key['map_blueprint_dark']['value'] == '/static/images/floorplan-placeholder.svg' + # No leftover West Jefferson sitemap references. + assert 'sitemap2025' not in by_key['map_blueprint_light']['value'] diff --git a/tests/test_core/test_slides.py b/tests/test_core/test_slides.py new file mode 100644 index 00000000..1a6a04b3 --- /dev/null +++ b/tests/test_core/test_slides.py @@ -0,0 +1,25 @@ +"""Characterization test for the slides (TV slideshow) plugin feed. + +The slide manager rework moved the public playlist to /api/slides/feed with a +flat (non-enveloped) response shape so the screensaver's existing parser needs +no change. Pin that contract here. +""" + + +def test_slides_feed_shape(client, db): + """GET /api/slides/feed returns a flat playlist for a surface (no auth).""" + resp = client.get('/api/slides/feed?surface=lobby') + assert resp.status_code == 200 + data = resp.get_json() + assert data['success'] is True + assert data['surface'] == 'lobby' + assert isinstance(data['slides'], list) + assert data['basepath'] == '/api/slides/img/lobby/' + assert 'interval' in data + + +def test_slides_feed_unknown_surface_falls_back(client, db): + """An invalid surface name falls back to lobby instead of erroring.""" + resp = client.get('/api/slides/feed?surface=../etc') + assert resp.status_code == 200 + assert resp.get_json()['surface'] == 'lobby' diff --git a/tests/test_core/test_supportteams.py b/tests/test_core/test_supportteams.py new file mode 100644 index 00000000..97e93674 --- /dev/null +++ b/tests/test_core/test_supportteams.py @@ -0,0 +1,234 @@ +"""Support teams + contacts: team/contact CRUD, lookup, delete-guard, import. + +The application/support-team wiring: a team owns ordered contacts, an +application carries one team, and its payload flattens the team name/url + +active contacts so the frontend needs a single call. +""" + +IMPORT_HEADER = {'X-Import-Mode': 'true'} +LEGACY_CREATED = '2020-01-05 08:30:00' +LEGACY_MODIFIED = '2021-06-07T14:15:16' + + +def _import_headers(auth_headers): + merged = dict(auth_headers) + merged.update(IMPORT_HEADER) + return merged + + +def _create_team(client, auth_headers, teamname='Controls', teamurl=None): + return client.post('/api/supportteams', + json={'teamname': teamname, 'teamurl': teamurl}, + headers=auth_headers) + + +# --------------------------------------------------------------------------- +# Team CRUD +# --------------------------------------------------------------------------- + +def test_create_and_get_team(client, db, auth_headers): + """Create a team, then fetch it back with an (empty) contacts list.""" + resp = _create_team(client, auth_headers, 'Controls', + 'https://servicenow.example/group/controls') + assert resp.status_code == 201, resp.get_json() + teamid = resp.get_json()['data']['supportteamid'] + + got = client.get(f'/api/supportteams/{teamid}', headers=auth_headers) + assert got.status_code == 200 + data = got.get_json()['data'] + assert data['teamname'] == 'Controls' + assert data['teamurl'] == 'https://servicenow.example/group/controls' + assert data['contacts'] == [] + + +def test_create_team_requires_teamname(client, db, auth_headers): + resp = client.post('/api/supportteams', json={}, headers=auth_headers) + assert resp.status_code == 400 + + +def test_create_team_duplicate_conflict(client, db, auth_headers): + _create_team(client, auth_headers, 'Controls') + dup = _create_team(client, auth_headers, 'Controls') + assert dup.status_code == 409 + + +def test_update_team(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + resp = client.put(f'/api/supportteams/{teamid}', + json={'teamname': 'Controls Renamed', + 'teamurl': 'https://x.example'}, + headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['teamname'] == 'Controls Renamed' + + +def test_delete_team(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'ToDelete').get_json()['data']['supportteamid'] + resp = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers) + assert resp.status_code == 200 + gone = client.get(f'/api/supportteams/{teamid}', headers=auth_headers) + assert gone.status_code == 404 + + +# --------------------------------------------------------------------------- +# teamname exact-match lookup (import recipe) + active filter +# --------------------------------------------------------------------------- + +def test_teamname_lookup_filter(client, db, auth_headers): + for name in ('Controls', 'ControlsB', 'Networking'): + _create_team(client, auth_headers, name) + listed = client.get('/api/supportteams?teamname=Controls', headers=auth_headers) + assert listed.status_code == 200 + rows = listed.get_json()['data'] + assert len(rows) == 1 + assert rows[0]['teamname'] == 'Controls' + + +def test_list_active_filter_hides_inactive(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Retired').get_json()['data']['supportteamid'] + client.put(f'/api/supportteams/{teamid}', json={'isactive': False}, + headers=auth_headers) + active = client.get('/api/supportteams', headers=auth_headers) + assert all(t['teamname'] != 'Retired' for t in active.get_json()['data']) + allteams = client.get('/api/supportteams?active=false', headers=auth_headers) + assert any(t['teamname'] == 'Retired' for t in allteams.get_json()['data']) + + +# --------------------------------------------------------------------------- +# Delete-with-applications 409 +# --------------------------------------------------------------------------- + +def test_delete_team_with_applications_conflicts(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'InUse').get_json()['data']['supportteamid'] + appresp = client.post('/api/applications', + json={'appname': 'DependentApp', + 'supportteamid': teamid}, + headers=auth_headers) + assert appresp.status_code == 201, appresp.get_json() + + conflict = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers) + assert conflict.status_code == 409 + # Still there. + assert client.get(f'/api/supportteams/{teamid}', + headers=auth_headers).status_code == 200 + + +# --------------------------------------------------------------------------- +# Nested contact CRUD + ordering +# --------------------------------------------------------------------------- + +def test_contact_crud_and_ordering(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + + # Add two contacts out of sort order. + c2 = client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'Second', 'sso': '222', 'sortorder': 2}, + headers=auth_headers) + assert c2.status_code == 201, c2.get_json() + c1 = client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'First', 'sso': '111', 'sortorder': 1}, + headers=auth_headers) + assert c1.status_code == 201 + + # Team now nests active contacts in sortorder. + team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data'] + names = [c['name'] for c in team['contacts']] + assert names == ['First', 'Second'] + + # Update one contact. + contactid = c1.get_json()['data']['contactid'] + upd = client.put(f'/api/supportteams/{teamid}/contacts/{contactid}', + json={'name': 'First Updated'}, headers=auth_headers) + assert upd.status_code == 200 + assert upd.get_json()['data']['name'] == 'First Updated' + + # Delete the other contact. + otherid = c2.get_json()['data']['contactid'] + dele = client.delete(f'/api/supportteams/{teamid}/contacts/{otherid}', + headers=auth_headers) + assert dele.status_code == 200 + team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data'] + assert [c['name'] for c in team['contacts']] == ['First Updated'] + + +def test_contact_requires_name(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + resp = client.post(f'/api/supportteams/{teamid}/contacts', json={}, + headers=auth_headers) + assert resp.status_code == 400 + + +def test_delete_team_cascades_contacts(client, db, auth_headers): + from shopdb.core.models import SupportTeamContact + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'Someone'}, headers=auth_headers) + client.delete(f'/api/supportteams/{teamid}', headers=auth_headers) + assert SupportTeamContact.query.filter_by(supportteamid=teamid).count() == 0 + + +# --------------------------------------------------------------------------- +# Application carries the team + flattened contacts +# --------------------------------------------------------------------------- + +def test_application_carries_supportteam_payload(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls', + 'https://sn.example/controls').get_json()['data']['supportteamid'] + client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'Alice', 'sso': 'a01', 'sortorder': 0}, + headers=auth_headers) + + appresp = client.post('/api/applications', + json={'appname': 'TeamApp', 'supportteamid': teamid}, + headers=auth_headers) + appid = appresp.get_json()['data']['appid'] + + got = client.get(f'/api/applications/{appid}', headers=auth_headers) + data = got.get_json()['data'] + assert data['supportteamid'] == teamid + assert data['supportteamname'] == 'Controls' + assert data['teamurl'] == 'https://sn.example/controls' + assert data['contacts'] == [{'name': 'Alice', 'sso': 'a01'}] + + +# --------------------------------------------------------------------------- +# Import-mode timestamps +# --------------------------------------------------------------------------- + +def test_import_mode_preserves_team_timestamps(client, db, auth_headers): + resp = client.post('/api/supportteams', + json={'teamname': 'Legacy', + 'createddate': LEGACY_CREATED, + 'modifieddate': LEGACY_MODIFIED}, + headers=_import_headers(auth_headers)) + assert resp.status_code == 201, resp.get_json() + + from shopdb.core.models import SupportTeam + team = SupportTeam.query.filter_by(teamname='Legacy').first() + assert team.createddate.year == 2020 and team.createddate.month == 1 + assert team.modifieddate.year == 2021 + + +def test_import_mode_preserves_contact_timestamps(client, db, auth_headers): + teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid'] + resp = client.post(f'/api/supportteams/{teamid}/contacts', + json={'name': 'LegacyOwner', + 'createddate': LEGACY_CREATED, + 'modifieddate': LEGACY_MODIFIED}, + headers=_import_headers(auth_headers)) + assert resp.status_code == 201, resp.get_json() + + from shopdb.core.models import SupportTeamContact + contact = SupportTeamContact.query.filter_by(name='LegacyOwner').first() + assert contact.createddate.year == 2020 + assert contact.modifieddate.year == 2021 + + +# --------------------------------------------------------------------------- +# Authz (belt-and-suspenders; the sweep in test_authz also covers these) +# --------------------------------------------------------------------------- + +def test_member_cannot_create_team(client, db, member_headers): + resp = client.post('/api/supportteams', json={'teamname': 'X'}, + headers=member_headers) + assert resp.status_code == 403 diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py new file mode 100644 index 00000000..2ea0f1cf --- /dev/null +++ b/tests/test_plugin_contract.py @@ -0,0 +1,309 @@ +"""Plugin contract compliance tests. + +Asserts every bundled plugin satisfies the contract surface declared in +ADR-001 and the BasePlugin ABC. Run on every change to BasePlugin or any +plugin's plugin.py / manifest.json. +""" + +import json +import re +from pathlib import Path + +import pytest +from flask import Blueprint + +from shopdb import __contract_version__ +from shopdb.plugins import plugin_manager +from shopdb.plugins.base import BasePlugin, PluginMeta + + +BUNDLED_PLUGINS = ('computers', 'employees', 'geenforce', 'knowledgebase', 'machines', 'measuringtools', 'network', 'notifications', 'printers', 'slides', 'usb', 'warranty') + + +@pytest.fixture +def plugin_classes(app): + """Load each bundled plugin's class without instantiating in app context.""" + classes = {} + with app.app_context(): + loader = plugin_manager.loader + for name in BUNDLED_PLUGINS: + cls = loader.load_plugin_class(name) + assert cls is not None, f'Plugin class for {name} could not be loaded' + classes[name] = cls + return classes + + +@pytest.fixture +def plugin_instances(app, plugin_classes): + """Instantiate each bundled plugin in app context.""" + return {name: cls() for name, cls in plugin_classes.items()} + + +def test_contract_version_is_set(): + """Framework declares a contract version.""" + assert __contract_version__ + parts = __contract_version__.split('.') + assert len(parts) == 3, f'Expected semver X.Y.Z, got {__contract_version__}' + for part in parts: + assert part.isdigit(), f'Non-numeric semver part in {__contract_version__}' + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_class_subclasses_baseplugin(plugin_classes, name): + """Each plugin class extends BasePlugin.""" + cls = plugin_classes[name] + assert issubclass(cls, BasePlugin), f'{name} does not subclass BasePlugin' + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_manifest_has_required_fields(name): + """Each plugin's manifest.json declares the required fields.""" + manifestpath = Path('plugins') / name / 'manifest.json' + assert manifestpath.exists(), f'{name} is missing manifest.json' + + manifest = json.loads(manifestpath.read_text()) + + assert manifest.get('name') == name + assert manifest.get('version'), f'{name} manifest missing version' + assert manifest.get('description'), f'{name} manifest missing description' + assert manifest.get('core_version'), f'{name} manifest missing core_version' + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_meta_returns_pluginmeta(plugin_instances, name): + """plugin.meta returns a PluginMeta instance with sane fields.""" + plugin = plugin_instances[name] + meta = plugin.meta + assert isinstance(meta, PluginMeta) + assert meta.name == name + assert meta.version + assert meta.api_prefix and meta.api_prefix.startswith('/api/') + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_blueprint_returns_blueprint_or_none(plugin_instances, name): + """get_blueprint returns a Flask Blueprint or None.""" + plugin = plugin_instances[name] + bp = plugin.get_blueprint() + assert bp is None or isinstance(bp, Blueprint), ( + f'{name}.get_blueprint returned {type(bp).__name__}' + ) + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_models_returns_list(plugin_instances, name): + """get_models returns a list of model classes.""" + plugin = plugin_instances[name] + models = plugin.get_models() + assert isinstance(models, list), f'{name}.get_models did not return a list' + for model in models: + assert hasattr(model, '__tablename__'), ( + f'{name}.get_models returned {model} without __tablename__' + ) + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_collector_schema_returns_dict_or_none(plugin_instances, name): + """get_collector_schema returns None or a dict with identityfield + fields.""" + plugin = plugin_instances[name] + schema = plugin.get_collector_schema() + if schema is None: + return + assert isinstance(schema, dict) + assert 'identityfield' in schema, ( + f'{name}.get_collector_schema must declare identityfield' + ) + assert 'fields' in schema, ( + f'{name}.get_collector_schema must declare fields' + ) + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_navigation_items_is_iterable(plugin_instances, name): + """get_navigation_items returns an iterable (default empty list).""" + plugin = plugin_instances[name] + items = plugin.get_navigation_items() + assert isinstance(items, list) + + +def test_baseplugin_has_no_searchable_fields_hook(): + """get_searchable_fields was removed: global search is a core concern over + the asset model, and no plugin ever implemented the hook (contract 0.4.0).""" + assert not hasattr(BasePlugin, 'get_searchable_fields') + + +def test_baseplugin_has_dashboard_widgets_hook(): + """The dashboard widgets hook is on the contract surface and consumed.""" + assert hasattr(BasePlugin, 'get_dashboard_widgets') + + +def test_baseplugin_has_reports_hook(): + """The reports hook is on the contract surface (contract 0.6.0).""" + assert hasattr(BasePlugin, 'get_reports') + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_reports_is_iterable(plugin_instances, name): + """get_reports returns a list (default empty).""" + plugin = plugin_instances[name] + reports = plugin.get_reports() + assert isinstance(reports, list) + # Each card carries an id/name/category plus exactly one of route/endpoint. + for card in reports: + assert card.get('id') and card.get('name') and card.get('category') + assert ('route' in card) ^ ('endpoint' in card), ( + f'{name} report card must have exactly one of route/endpoint' + ) + + +def test_baseplugin_has_permissions_hook(): + """The permissions hook is on the contract surface (contract 0.10.0).""" + assert hasattr(BasePlugin, 'get_permissions') + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_get_permissions_shape(plugin_instances, name): + """get_permissions returns a list of (name, description, category) entries.""" + plugin = plugin_instances[name] + perms = plugin.get_permissions() + assert isinstance(perms, list) + for entry in perms: + if isinstance(entry, dict): + pname, category = entry['name'], entry.get('category') + else: + assert len(entry) == 3, f'{name}: entry must be a 3-tuple' + pname, _desc, category = entry + assert isinstance(pname, str) and '.' in pname, ( + f'{name}: permission name {pname!r} must be dotted') + assert category, f'{name}: permission {pname} needs a category' + + +def test_baseplugin_has_frontend_contribution_hooks(): + """The four ADR-010 frontend-contribution hooks are on the contract (0.7.0).""" + for hook in ('get_settings_cards', 'get_asset_panels', + 'get_map_overlays', 'get_asset_presentation'): + assert hasattr(BasePlugin, hook), f'{hook} missing from BasePlugin' + + +@pytest.mark.parametrize('name', BUNDLED_PLUGINS) +def test_plugin_frontend_hooks_return_lists(plugin_instances, name): + """The four ADR-010 hooks default to a list on every bundled plugin.""" + plugin = plugin_instances[name] + assert isinstance(plugin.get_settings_cards(), list) + assert isinstance(plugin.get_asset_panels(), list) + assert isinstance(plugin.get_map_overlays(), list) + assert isinstance(plugin.get_asset_presentation(), list) + + +def test_get_services_hook_has_consumer(app): + """get_services is consumed by plugin_manager.get_service (no dead hook).""" + with app.app_context(): + pm = app.extensions['plugin_manager'] + assert hasattr(pm, 'get_service') + # Unknown service name resolves to None, not an error. + assert pm.get_service('definitely-not-a-real-service') is None + + +def test_baseplugin_does_not_have_event_handlers_hook(): + """The removed get_event_handlers hook must not be on BasePlugin.""" + assert not hasattr(BasePlugin, 'get_event_handlers'), ( + 'get_event_handlers was removed for v1. See ADR-001 contract surface.' + ) + + +def test_baseplugin_has_collector_schema_hook(): + """The collector schema hook is on the contract surface.""" + assert hasattr(BasePlugin, 'get_collector_schema') + + +def test_baseplugin_has_apply_collector_payload_hook(): + """The collector upsert hook is on the contract surface (ADR-006).""" + assert hasattr(BasePlugin, 'apply_collector_payload') + + +def test_schema_declaring_plugins_implement_apply(plugin_instances): + """Any plugin returning a collector schema must implement the upsert hook.""" + for name, plugin in plugin_instances.items(): + if plugin.get_collector_schema() is not None: + overridden = type(plugin).apply_collector_payload \ + is not BasePlugin.apply_collector_payload + assert overridden, ( + f'Plugin {name} declares a collector schema but does not ' + f'override apply_collector_payload' + ) + + +# Imports a plugin may make from the core. shopdb.api is the contract surface; +# shopdb.plugins.base is the plugin ABC. Anything else (shopdb.core.*, +# shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001. +ALLOWED_CORE_IMPORTS = ('shopdb.api', 'shopdb.plugins.base') + +_PLUGIN_IMPORT_RE = re.compile( + r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE +) + + +def _plugin_source_files(): + root = Path(__file__).resolve().parent.parent / 'plugins' + # Skip migrations/ - Alembic env.py is framework glue that legitimately + # calls the shared runner in shopdb.plugins.alembic_template (see ADR-008), + # not plugin domain code. Mirrors the style check excluding versions/. + # Resolve each plugin dir before rglob: rglob does not descend symlinked + # directories, and external plugins arrive as symlinks per ADR-003. + files = [p for p in root.glob('*.py')] + for plugindir in sorted(root.iterdir()): + if not plugindir.is_dir(): + continue + for path in plugindir.resolve().rglob('*.py'): + if '__pycache__' in path.parts or 'migrations' in path.parts: + continue + files.append(path) + return files + + +def test_plugins_only_import_contract_surface(): + """Plugins must import core code only via shopdb.api / shopdb.plugins.base.""" + violations = [] + for path in _plugin_source_files(): + text = path.read_text() + for match in _PLUGIN_IMPORT_RE.finditer(text): + module = match.group(1) or match.group(2) + if not module.startswith('shopdb'): + continue + if any(module == a or module.startswith(a + '.') + for a in ALLOWED_CORE_IMPORTS): + continue + line = text[:match.start()].count('\n') + 1 + violations.append(f'{path.name}:{line} imports {module}') + assert not violations, ( + 'Plugins must import core only via shopdb.api or shopdb.plugins.base. ' + 'Violations:\n' + '\n'.join(violations) + ) + + +def test_import_scan_covers_symlinked_plugins(tmp_path): + """External plugins symlinked into plugins/ ARE scanned for violations. + + Path.rglob does not descend symlinked directories, so a naive scan would + silently skip external plugins installed per ADR-003 (clone or symlink + into plugins//). The scanner resolves each plugin dir first; this + test plants a symlinked plugin with a violating import and asserts the + scan both sees the file and flags the violation. + """ + root = Path(__file__).resolve().parent.parent / 'plugins' + external = tmp_path / 'symlinkdemo' + external.mkdir() + # violating import: internal core path, not the contract surface + (external / 'plugin.py').write_text('from shopdb.extensions import db\n') + + link = root / 'zzsymlinkdemo' + link.symlink_to(external, target_is_directory=True) + try: + scanned = {str(p) for p in _plugin_source_files()} + assert any('symlinkdemo' in s for s in scanned), ( + 'symlinked plugin sources were not scanned' + ) + with pytest.raises(AssertionError, match='shopdb.extensions'): + test_plugins_only_import_contract_surface() + finally: + link.unlink() diff --git a/tests/test_plugin_lifecycle.py b/tests/test_plugin_lifecycle.py new file mode 100644 index 00000000..1d0d6290 --- /dev/null +++ b/tests/test_plugin_lifecycle.py @@ -0,0 +1,165 @@ +"""Plugin lifecycle + registry tests. + +Covers the framework's own core feature - the plugin manager's enable/disable +guards and permission seeding, plus the registry's persistence and the +equipment->machines rename migration. These paths had no direct coverage; a +dropped dependency check or a broken rename migration would let an operator +disable a required plugin (breaking boot) with no failing test. +""" + +import json + +import pytest + +from shopdb.plugins import PluginManager +from shopdb.plugins.loader import PluginLoader +from shopdb.plugins.registry import PluginRegistry + + +# --- Registry persistence + rename migration -------------------------------- + +def test_registry_disable_survives_reload(tmp_path): + """A disabled plugin stays disabled when the registry is re-read.""" + path = tmp_path / 'plugins.json' + reg = PluginRegistry(path) + reg.register('demo', '1.0.0', enabled=True) + reg.disable('demo') + + reloaded = PluginRegistry(path) + assert reloaded.is_installed('demo') + assert not reloaded.is_enabled('demo') + + +def test_registry_recovers_from_corrupt_file(tmp_path): + """An unparseable plugins.json loads as empty instead of crashing.""" + path = tmp_path / 'plugins.json' + path.write_text('{ this is not valid json') + reg = PluginRegistry(path) + assert reg.get_all() == {} + + +def test_registry_migrates_equipment_to_machines(tmp_path): + """A legacy 'equipment' entry is carried over to 'machines' on load (the + real plugins/machines dir exists), preserving enabled state, once.""" + path = tmp_path / 'plugins.json' + seed = PluginRegistry(path) + seed.register('equipment', '1.0.0', enabled=True) + assert seed.is_installed('equipment') + + migrated = PluginRegistry(path) # _load runs the rename migration + assert migrated.is_installed('machines') + assert migrated.is_enabled('machines') + assert not migrated.is_installed('equipment') + + # Persisted: a third read sees machines, and never re-migrates. + again = PluginRegistry(path) + assert again.is_installed('machines') + + +# --- Manager enable/disable dependency guards + permission seeding ----------- + +def _write_plugin(plugins_dir, name, deps=None, permissions=None): + """Write a minimal valid synthetic plugin (manifest.json + plugin.py).""" + pdir = plugins_dir / name + pdir.mkdir() + (pdir / 'manifest.json').write_text(json.dumps({ + 'name': name, + 'version': '1.0.0', + 'description': f'synthetic {name}', + 'dependencies': deps or [], + 'core_version': '>=0.1.0,<1.0.0', + 'api_prefix': f'/api/{name}', + })) + (pdir / '__init__.py').write_text('') + (pdir / 'plugin.py').write_text( + 'from shopdb.plugins.base import BasePlugin, PluginMeta\n\n' + 'class ThePlugin(BasePlugin):\n' + ' @property\n' + ' def meta(self):\n' + f' return PluginMeta(name={name!r}, version="1.0.0",\n' + f' description="synthetic", dependencies={deps or []!r})\n' + ' def get_blueprint(self):\n' + ' return None\n' + ' def get_models(self):\n' + ' return []\n' + ' def get_permissions(self):\n' + f' return {permissions or []!r}\n' + ) + + +@pytest.fixture +def manager(app, db, tmp_path): + """A PluginManager wired to a temp plugins dir + registry, with synthetic + plugins 'alpha' (owns a permission) and 'beta' (depends on alpha).""" + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + _write_plugin(plugins_dir, 'alpha', + permissions=[('alpha.view', 'View alpha', 'alpha')]) + _write_plugin(plugins_dir, 'beta', deps=['alpha']) + + registry = PluginRegistry(tmp_path / 'plugins.json') + registry.register('alpha', '1.0.0', enabled=False) + registry.register('beta', '1.0.0', enabled=False) + + pluginmanager = PluginManager() + pluginmanager._app = app + pluginmanager._db = db + pluginmanager.registry = registry + pluginmanager.loader = PluginLoader(plugins_dir, registry) + pluginmanager.migration_manager = None + return pluginmanager + + +def test_enable_blocked_until_dependency_enabled(manager): + """beta depends on alpha: enabling beta first is refused.""" + assert manager.enable_plugin('beta') is False + assert not manager.registry.is_enabled('beta') + + assert manager.enable_plugin('alpha') is True + assert manager.enable_plugin('beta') is True + + +def test_disable_blocked_while_dependent_enabled(manager): + """alpha cannot be disabled while beta (which depends on it) is enabled.""" + manager.enable_plugin('alpha') + manager.enable_plugin('beta') + + assert manager.disable_plugin('alpha') is False + assert manager.registry.is_enabled('alpha') + + # Disable the dependent first, then alpha frees up. + assert manager.disable_plugin('beta') is True + assert manager.disable_plugin('alpha') is True + + +def test_enable_seeds_plugin_permissions(manager, app, db): + """Enabling a plugin idempotently seeds its declared RBAC permissions.""" + from shopdb.core.models import Permission + + with app.app_context(): + assert Permission.query.filter_by(name='alpha.view').first() is None + + assert manager.enable_plugin('alpha') is True + with app.app_context(): + assert Permission.query.filter_by(name='alpha.view').first() is not None + + # Re-enable is a no-op (already enabled) and never duplicates the row. + manager.enable_plugin('alpha') + with app.app_context(): + assert Permission.query.filter_by(name='alpha.view').count() == 1 + + +# --- Seed idempotency ------------------------------------------------------- + +def test_seed_settings_is_idempotent(app, db): + """A second `flask seed settings` pass creates zero net-new rows.""" + from shopdb.core.models import Setting + + runner = app.test_cli_runner() + assert runner.invoke(args=['seed', 'settings']).exit_code in (0, None) + with app.app_context(): + first = Setting.query.count() + assert runner.invoke(args=['seed', 'settings']).exit_code in (0, None) + with app.app_context(): + second = Setting.query.count() + assert second == first diff --git a/tests/test_plugin_loader.py b/tests/test_plugin_loader.py new file mode 100644 index 00000000..e31d4aaa --- /dev/null +++ b/tests/test_plugin_loader.py @@ -0,0 +1,261 @@ +"""Tests for the plugin loader behavior. + +Pins the manifest-first design and the strict-vs-isolate failure policy +described in the enforcing-plugin-contract skill. +""" + +import json +from pathlib import Path + +import pytest + +from shopdb.plugins.loader import PluginLoader +from shopdb.plugins.registry import PluginRegistry +from shopdb.exceptions import ( + PluginNotFoundError, + PluginContractError, + PluginVersionError, + PluginDependencyError, + PluginError, +) + + +@pytest.fixture +def temp_plugins_dir(tmp_path): + """An empty temporary plugins directory.""" + plugins = tmp_path / 'plugins' + plugins.mkdir() + return plugins + + +@pytest.fixture +def temp_registry(tmp_path): + """An empty temporary registry.""" + return PluginRegistry(tmp_path / 'plugins.json') + + +@pytest.fixture +def loader(temp_plugins_dir, temp_registry): + """A loader pointed at the temporary plugins dir.""" + return PluginLoader(temp_plugins_dir, temp_registry) + + +def write_plugin(plugins_dir, name, manifest=None, plugin_py=None): + """Helper: write a plugin directory with manifest.json and plugin.py.""" + plugin_dir = plugins_dir / name + plugin_dir.mkdir() + + if manifest is not None: + (plugin_dir / 'manifest.json').write_text(json.dumps(manifest)) + + if plugin_py is not None: + (plugin_dir / 'plugin.py').write_text(plugin_py) + + return plugin_dir + + +VALID_PLUGIN_PY = ''' +from shopdb.plugins.base import BasePlugin, PluginMeta + + +class FakePlugin(BasePlugin): + @property + def meta(self): + return PluginMeta( + name='fake', + version='1.0.0', + description='Fake test plugin', + ) + + def get_blueprint(self): + return None + + def get_models(self): + return [] +''' + + +def test_load_manifest_raises_on_missing_manifest(loader, temp_plugins_dir): + """A plugin without manifest.json raises PluginNotFoundError.""" + plugin_dir = temp_plugins_dir / 'noplugin' + plugin_dir.mkdir() + (plugin_dir / 'plugin.py').write_text('# empty') + + with pytest.raises(PluginNotFoundError, match='manifest.json'): + loader.load_manifest('noplugin') + + +def test_load_manifest_raises_on_invalid_json(loader, temp_plugins_dir): + """An unparseable manifest.json raises PluginContractError.""" + plugin_dir = temp_plugins_dir / 'bad' + plugin_dir.mkdir() + (plugin_dir / 'manifest.json').write_text('{not json}') + + with pytest.raises(PluginContractError, match='invalid manifest.json'): + loader.load_manifest('bad') + + +def test_load_manifest_raises_on_missing_required_fields(loader, temp_plugins_dir): + """Missing name/version/description raises PluginContractError.""" + write_plugin(temp_plugins_dir, 'incomplete', manifest={'name': 'incomplete'}) + + with pytest.raises(PluginContractError, match='missing required field'): + loader.load_manifest('incomplete') + + +def test_load_manifest_raises_on_name_mismatch(loader, temp_plugins_dir): + """manifest.name must match the directory name.""" + write_plugin( + temp_plugins_dir, + 'mydir', + manifest={'name': 'different', 'version': '1.0.0', 'description': 'x'}, + ) + + with pytest.raises(PluginContractError, match='does not match manifest name'): + loader.load_manifest('mydir') + + +def test_check_contract_version_passes_when_in_range(loader, temp_plugins_dir): + """A core_version range that covers the framework version passes.""" + write_plugin( + temp_plugins_dir, + 'compat', + manifest={ + 'name': 'compat', + 'version': '1.0.0', + 'description': 'x', + 'core_version': '>=0.1.0,<1.0.0', + }, + ) + loader.check_contract_version('compat', '0.1.0') + + +def test_check_contract_version_raises_when_out_of_range(loader, temp_plugins_dir): + """A core_version range that excludes the framework version raises.""" + write_plugin( + temp_plugins_dir, + 'incompat', + manifest={ + 'name': 'incompat', + 'version': '1.0.0', + 'description': 'x', + 'core_version': '>=2.0.0', + }, + ) + + with pytest.raises(PluginVersionError, match='requires core_version'): + loader.check_contract_version('incompat', '0.1.0') + + +def test_check_contract_version_skipped_when_unspecified(loader, temp_plugins_dir): + """A manifest without core_version does not raise.""" + write_plugin( + temp_plugins_dir, + 'unspec', + manifest={ + 'name': 'unspec', + 'version': '1.0.0', + 'description': 'x', + }, + ) + loader.check_contract_version('unspec', '0.1.0') + + +def test_load_plugin_class_raises_on_missing_plugin_py(loader, temp_plugins_dir): + """A directory with no plugin.py raises PluginNotFoundError.""" + plugin_dir = temp_plugins_dir / 'nofile' + plugin_dir.mkdir() + + with pytest.raises(PluginNotFoundError, match='plugin.py not found'): + loader.load_plugin_class('nofile') + + +def test_load_plugin_class_raises_when_no_subclass_found(loader, temp_plugins_dir): + """A plugin.py without a BasePlugin subclass raises PluginContractError.""" + write_plugin( + temp_plugins_dir, + 'empty', + manifest={'name': 'empty', 'version': '1.0.0', 'description': 'x'}, + plugin_py='# no plugin defined', + ) + + with pytest.raises(PluginContractError, match='no BasePlugin subclass'): + loader.load_plugin_class('empty') + + +def test_load_plugin_class_raises_on_import_error(loader, temp_plugins_dir): + """A plugin.py with a syntax/import error raises PluginContractError.""" + write_plugin( + temp_plugins_dir, + 'broken', + manifest={'name': 'broken', 'version': '1.0.0', 'description': 'x'}, + plugin_py='import nonexistent_module_xyz', + ) + + with pytest.raises(PluginContractError, match='import failed'): + loader.load_plugin_class('broken') + + +def test_sort_by_dependencies_uses_manifest_not_instantiation(loader, temp_plugins_dir): + """Topological sort reads manifest.json directly, never instantiates.""" + write_plugin( + temp_plugins_dir, + 'a', + manifest={ + 'name': 'a', + 'version': '1.0.0', + 'description': 'x', + 'dependencies': ['b'], + }, + ) + write_plugin( + temp_plugins_dir, + 'b', + manifest={'name': 'b', 'version': '1.0.0', 'description': 'x'}, + ) + + sorted_names = loader._sort_by_dependencies(['a', 'b']) + assert sorted_names.index('b') < sorted_names.index('a') + + +def test_load_plugin_strict_mode_reraises(loader, app, temp_plugins_dir): + """In TESTING/DEBUG mode, load_plugin re-raises plugin errors.""" + write_plugin( + temp_plugins_dir, + 'incompat', + manifest={ + 'name': 'incompat', + 'version': '1.0.0', + 'description': 'x', + 'core_version': '>=99.0.0', + }, + plugin_py=VALID_PLUGIN_PY, + ) + + assert app.config.get('TESTING') is True + with pytest.raises(PluginVersionError): + loader.load_plugin('incompat', app, None) + + +def test_load_plugin_production_mode_isolates(loader, temp_plugins_dir): + """In production-like config, load_plugin returns None on failure.""" + from flask import Flask + + fake_app = Flask(__name__) + fake_app.config['TESTING'] = False + fake_app.config['DEBUG'] = False + + write_plugin( + temp_plugins_dir, + 'incompat', + manifest={ + 'name': 'incompat', + 'version': '1.0.0', + 'description': 'x', + 'core_version': '>=99.0.0', + }, + plugin_py=VALID_PLUGIN_PY, + ) + + result = loader.load_plugin('incompat', fake_app, None) + assert result is None diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py new file mode 100644 index 00000000..16e02690 --- /dev/null +++ b/tests/test_plugin_migrations.py @@ -0,0 +1,242 @@ +"""Per-plugin Alembic migration-chain guard tests. + +Ownership cutover (ADR-008): the core Alembic chain created every table that +exists through its head (`7d16_directoryemployees`), including the plugin +tables. From that point on, each bundled plugin that owns tables carries its +own chain under `plugins//migrations/`. The `0001` migration in each +chain is a stamp-only no-op anchor: the core chain already built the tables, so +there is nothing to create; the anchor just gives the plugin chain a base that +`flask plugin upgrade-all` stamps into `alembic_version_`. + +These tests pin that contract: + * PLUGIN_TABLE_OWNERS stays in sync with what the models declare. + * Every plugin that owns tables has a valid single-head chain. + * The anchor migrations are genuine no-ops. + * `flask plugin upgrade-all` runs clean on a fresh DB and is idempotent. +""" +import ast +from pathlib import Path + +import pytest + +from shopdb.plugins.alembic_template import ( + PLUGIN_TABLE_OWNERS, + _get_plugin_metadata, +) + +PLUGINS_DIR = Path(__file__).resolve().parent.parent / 'plugins' + +# Plugins that own tables carry a migration chain; blueprint-only plugins do +# not. Today every bundled plugin owns tables, so this is the full set. +TABLE_OWNING_PLUGINS = tuple(sorted(PLUGIN_TABLE_OWNERS)) + +# The ADR-008 cutover froze this exact set of ten plugins whose tables the core +# chain had already created. Their 0001 revision is a stamp-only no-op anchor. +# Plugins built AFTER the cutover (e.g. measuringtools) are NOT in this list: +# their 0001 is a real baseline that genuinely creates their tables, so the +# no-op assertion must not apply to them. This is a frozen list on purpose - a +# newly discovered plugin does not silently get treated as a cutover no-op. +CUTOVER_PLUGINS = ( + 'computers', 'employees', 'knowledgebase', 'machines', 'network', + 'notifications', 'printers', 'slides', 'usb', 'warranty', +) + +# Expected head revision id per table-owning plugin, so the upgrade-all test can +# check both the cutover anchors and post-cutover baselines. Cutover plugins +# stamp '0001anchor'; measuringtools stamps its real baseline id. +EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS} +EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline' +EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0001baseline' +# machines (renamed from equipment) keeps its original anchor id and adds the +# rename revision on top, so its head is not the f-string default. +EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' +# employees adds the photofilename column on top of its cutover anchor. +EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo' +# usb drops the dead usbcheckouts.machineid column on top of its anchor. +EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid' +# notifications indexes businessunitid on top of its anchor. +EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx' + +# Plugins built after the cutover: their 0001 baseline really creates tables the +# core chain never owned. +POST_CUTOVER_PLUGINS = tuple(p for p in PLUGIN_TABLE_OWNERS if p not in CUTOVER_PLUGINS) + + +def _declared_tablenames(plugin: str) -> set: + """Scan a plugin's models package for every __tablename__ literal. + + Static parse (no import) so the test can compare what the code declares + against PLUGIN_TABLE_OWNERS without side effects. + """ + names = set() + models_dir = PLUGINS_DIR / plugin / 'models' + if not models_dir.exists(): + return names + for source in models_dir.glob('*.py'): + tree = ast.parse(source.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + targets = [t.id for t in node.targets if isinstance(t, ast.Name)] + if '__tablename__' in targets and isinstance(node.value, ast.Constant): + names.add(node.value.value) + return names + + +def test_table_owners_match_declared_models(): + """Every plugin that declares a __tablename__ is registered in + PLUGIN_TABLE_OWNERS, and vice versa. Catches a new plugin table that + forgot to update the ownership map.""" + declared = {p.name for p in PLUGINS_DIR.iterdir() + if p.is_dir() and _declared_tablenames(p.name)} + assert declared == set(PLUGIN_TABLE_OWNERS), ( + f"PLUGIN_TABLE_OWNERS keys {set(PLUGIN_TABLE_OWNERS)} do not match " + f"plugins declaring tables {declared}" + ) + + +@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS) +def test_bundled_plugin_has_table_owner_entry(plugin): + """Every table-owning plugin appears in PLUGIN_TABLE_OWNERS with at least + one table, documenting which tables it contributes to the schema.""" + assert plugin in PLUGIN_TABLE_OWNERS + assert len(PLUGIN_TABLE_OWNERS[plugin]) > 0 + + +@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS) +def test_owned_tables_match_declared_models(plugin): + """The tables named in PLUGIN_TABLE_OWNERS are exactly the ones the + plugin's models declare. Catches drift in either direction.""" + assert set(PLUGIN_TABLE_OWNERS[plugin]) == _declared_tablenames(plugin) + + +@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS) +def test_plugin_metadata_has_all_owned_tables(plugin, app): + """The MetaData filtered to a plugin's owned tables actually contains + every table named in PLUGIN_TABLE_OWNERS.""" + with app.app_context(): + md = _get_plugin_metadata(plugin) + owned = set(PLUGIN_TABLE_OWNERS[plugin]) + missing = owned - set(md.tables.keys()) + assert not missing, f"Plugin {plugin}: owned tables not in metadata: {missing}" + + +@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS) +def test_plugin_has_migration_chain(plugin): + """Every table-owning plugin has a migrations dir with env.py and exactly + one anchor revision whose down_revision is None (a valid single-root + chain).""" + mig = PLUGINS_DIR / plugin / 'migrations' + assert (mig / 'env.py').exists(), f"{plugin}: missing migrations/env.py" + versions = sorted((mig / 'versions').glob('*.py')) + assert versions, f"{plugin}: no version scripts" + + roots = [] + heads = set() + down_revisions = set() + revisions = set() + for script in versions: + tree = ast.parse(script.read_text()) + rev = down = None + found_down = False + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + names = [t.id for t in node.targets if isinstance(t, ast.Name)] + if 'revision' in names and isinstance(node.value, ast.Constant): + rev = node.value.value + if 'down_revision' in names: + found_down = True + if isinstance(node.value, ast.Constant): + down = node.value.value + assert rev, f"{plugin}: {script.name} has no revision id" + assert found_down, f"{plugin}: {script.name} has no down_revision" + revisions.add(rev) + if down is None: + roots.append(rev) + else: + down_revisions.add(down) + heads = revisions - down_revisions + assert len(roots) == 1, f"{plugin}: expected 1 root revision, got {roots}" + assert len(heads) == 1, f"{plugin}: chain must have a single head, got {heads}" + + +@pytest.mark.parametrize('plugin', CUTOVER_PLUGINS) +def test_anchor_migration_is_noop(plugin): + """The 0001 anchor's upgrade() and downgrade() are pure no-ops: no DDL + operations, just `pass`. The core chain owns the tables at cutover. + + Scoped to CUTOVER_PLUGINS only. A plugin built after the cutover ships a + real baseline (measuringtools), which is deliberately NOT a no-op.""" + anchor = PLUGINS_DIR / plugin / 'migrations' / 'versions' / f'0001_{plugin}_anchor.py' + assert anchor.exists(), f"{plugin}: missing 0001 anchor migration" + tree = ast.parse(anchor.read_text()) + funcs = {n.name: n for n in ast.walk(tree) + if isinstance(n, ast.FunctionDef) and n.name in ('upgrade', 'downgrade')} + assert set(funcs) == {'upgrade', 'downgrade'}, f"{plugin}: anchor missing up/downgrade" + for name, fn in funcs.items(): + # Body may only be a docstring/comment plus a bare `pass`. No calls. + calls = [n for n in ast.walk(fn) if isinstance(n, ast.Call)] + assert not calls, f"{plugin}: anchor {name}() is not a no-op (found calls)" + + +def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch): + """`flask plugin upgrade-all` on a fresh SQLite DB (after the core schema + is created) stamps every plugin anchor without error, and a second run is + a no-op. Mirrors the deploy sequence: `flask db upgrade` then + `flask plugin upgrade-all`.""" + from sqlalchemy import inspect, text + from shopdb.config import TestingConfig + from shopdb import create_app + from shopdb.extensions import db + from shopdb.plugins import plugin_manager + + db_file = tmp_path / 'fresh.db' + url = f'sqlite:///{db_file}' + + # Point the whole app (db engine + migration manager) at one file DB so the + # anchor stamps land where the app can read them back. + monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI', url) + + # create_app repoints the process-wide plugin_manager singleton; snapshot + # its wiring and restore it so later tests see the session app unchanged. + saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) + try: + app = create_app('testing') + with app.app_context(): + db.create_all() # stand in for the core `flask db upgrade` + + # db.create_all() over-creates: it builds EVERY table registered on + # the metadata, including post-cutover plugin tables the core chain + # would never own. Drop those so each post-cutover baseline creates + # its own tables exactly as it does after a real core upgrade (where + # the tables are simply absent). The cutover anchors are no-ops, so + # their create_all-built tables stay put. + insp0 = inspect(db.engine) + for plugin in POST_CUTOVER_PLUGINS: + # Drop by owned name (SQLite tolerates any order with no rows); + # avoids resolving cross-metadata FKs via sorted_tables. + for tablename in PLUGIN_TABLE_OWNERS[plugin]: + if insp0.has_table(tablename): + db.session.execute(text(f'DROP TABLE {tablename}')) + db.session.commit() + + first = app.extensions['plugin_manager'].upgrade_all_plugins() + second = app.extensions['plugin_manager'].upgrade_all_plugins() + + assert set(first) == set(PLUGIN_TABLE_OWNERS) + assert all(status == 'ok' for status in first.values()), first + assert all(status == 'ok' for status in second.values()), second + + insp = inspect(db.engine) + for plugin in PLUGIN_TABLE_OWNERS: + version_table = f'alembic_version_{plugin}' + assert insp.has_table(version_table), f"missing {version_table}" + row = db.session.execute( + text(f'SELECT version_num FROM {version_table}') + ).fetchone() + assert row and row[0] == EXPECTED_HEAD_REVISION[plugin] + finally: + (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) = saved diff --git a/tests/test_plugin_scaffold.py b/tests/test_plugin_scaffold.py new file mode 100644 index 00000000..f7e63ede --- /dev/null +++ b/tests/test_plugin_scaffold.py @@ -0,0 +1,189 @@ +"""Canary tests for the plugin scaffolder. + +The scaffold output must satisfy the framework contract the moment it +is generated. If a contract test would fail on a freshly scaffolded +plugin, the scaffold templates are broken, not the plugin author's +mistake. +""" + +import json +import sys +from pathlib import Path + +import pytest +from flask import Blueprint + +from shopdb.plugins.scaffolder import ( + scaffold_plugin, + validate_name, + pascal_case, + ScaffoldError, +) + + +@pytest.fixture(scope='module') +def scaffolded(tmp_path_factory): + """Scaffold a plugin named 'widgets' under a temporary plugins dir. + + Module-scoped so the generated plugin module imports exactly once + per test module. SQLAlchemy registers the model once, no metadata + collision. Returns a tuple (target_dir, plugins_dir) where + plugins_dir is suitable as PluginLoader's plugins_dir argument. + + The scaffold output is loaded via PluginLoader's spec-based import + so the canary does not collide with the real `plugins/` package + that already exists at /plugins/. + """ + tmp_path = tmp_path_factory.mktemp('scaffold') + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + + target = scaffold_plugin( + name='widgets', + description='Test widgets plugin', + plugins_dir=plugins_dir, + ) + + import plugins as real_plugins + real_plugins.__path__.insert(0, str(plugins_dir)) + + try: + yield target, plugins_dir + finally: + try: + real_plugins.__path__.remove(str(plugins_dir)) + except ValueError: + pass + for mod_name in list(sys.modules): + if mod_name.startswith('plugins.widgets'): + del sys.modules[mod_name] + from shopdb.extensions import db as _db + if 'widgets' in _db.metadata.tables: + _db.metadata.remove(_db.metadata.tables['widgets']) + + +def _load_widgets_plugin_class(plugins_dir): + """Load the scaffolded widgets plugin class via PluginLoader.""" + from shopdb.plugins.loader import PluginLoader + from shopdb.plugins.registry import PluginRegistry + + registry = PluginRegistry(plugins_dir.parent / 'plugins.json') + loader = PluginLoader(plugins_dir, registry) + return loader.load_plugin_class('widgets') + + +def test_validate_name_accepts_simple_lowercase(): + validate_name('cameras') + validate_name('barcode') + validate_name('vlan2') + + +def test_validate_name_rejects_underscore(): + with pytest.raises(ScaffoldError, match='lowercase letters and digits'): + validate_name('my_plugin') + + +def test_validate_name_rejects_hyphen(): + with pytest.raises(ScaffoldError, match='lowercase letters and digits'): + validate_name('my-plugin') + + +def test_validate_name_rejects_uppercase(): + with pytest.raises(ScaffoldError, match='lowercase letters and digits'): + validate_name('MyPlugin') + + +def test_validate_name_rejects_leading_digit(): + with pytest.raises(ScaffoldError, match='lowercase letters and digits'): + validate_name('1plugin') + + +def test_validate_name_rejects_reserved(): + with pytest.raises(ScaffoldError, match='reserved'): + validate_name('plugins') + + +def test_pascal_case(): + assert pascal_case('cameras') == 'Cameras' + assert pascal_case('vlan') == 'Vlan' + + +def test_scaffold_creates_expected_files(scaffolded): + """Scaffold output has every file the contract expects.""" + target, _ = scaffolded + expected = [ + 'manifest.json', + 'plugin.py', + '__init__.py', + 'models/__init__.py', + 'models/widgets.py', + 'api/__init__.py', + 'api/routes.py', + 'schemas/__init__.py', + 'tests/__init__.py', + 'tests/test_plugin.py', + 'README.md', + ] + for relpath in expected: + assert (target / relpath).exists(), f'Missing scaffold output: {relpath}' + + +def test_scaffold_manifest_has_required_fields(scaffolded): + """manifest.json contains the fields the loader requires.""" + target, _ = scaffolded + manifest = json.loads((target / 'manifest.json').read_text()) + assert manifest['name'] == 'widgets' + assert manifest['version'] + assert manifest['description'] == 'Test widgets plugin' + assert manifest['core_version'] + assert manifest['api_prefix'] == '/api/widgets' + + +def test_scaffold_plugin_class_is_importable(scaffolded): + """The generated plugin.py loads via PluginLoader and subclasses BasePlugin.""" + from shopdb.plugins.base import BasePlugin + + _, plugins_dir = scaffolded + plugin_class = _load_widgets_plugin_class(plugins_dir) + + assert issubclass(plugin_class, BasePlugin) + instance = plugin_class() + assert instance.meta.name == 'widgets' + + +def test_scaffold_get_blueprint_returns_blueprint(scaffolded): + """The generated plugin's get_blueprint returns a Flask Blueprint.""" + _, plugins_dir = scaffolded + plugin_class = _load_widgets_plugin_class(plugins_dir) + bp = plugin_class().get_blueprint() + assert isinstance(bp, Blueprint) + + +def test_scaffold_get_models_returns_model_class(scaffolded): + """The generated plugin's get_models returns at least one model with a tablename.""" + _, plugins_dir = scaffolded + plugin_class = _load_widgets_plugin_class(plugins_dir) + models = plugin_class().get_models() + assert len(models) == 1 + assert models[0].__tablename__ == 'widgets' + + +def test_scaffold_refuses_to_overwrite_by_default(tmp_path): + """A second scaffold of the same name without overwrite raises.""" + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + scaffold_plugin('widgets', 'first', plugins_dir) + + with pytest.raises(ScaffoldError, match='already exists'): + scaffold_plugin('widgets', 'second', plugins_dir) + + +def test_scaffold_overwrites_when_explicitly_requested(tmp_path): + """overwrite=True allows a re-scaffold.""" + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + scaffold_plugin('widgets', 'first', plugins_dir) + scaffold_plugin('widgets', 'second', plugins_dir, overwrite=True) + + manifest = json.loads((plugins_dir / 'widgets' / 'manifest.json').read_text()) + assert manifest['description'] == 'second' diff --git a/tests/test_plugin_scaffold_frontend.py b/tests/test_plugin_scaffold_frontend.py new file mode 100644 index 00000000..ec6e8dbf --- /dev/null +++ b/tests/test_plugin_scaffold_frontend.py @@ -0,0 +1,252 @@ +"""Canary tests for the plugin scaffolder's frontend generation. + +`flask plugin new ` generates the backend skeleton and, when a frontend +tree is present, a matching Vue frontend starting point: list, detail, and form +views, a router route file, and a paste-in api-client snippet. These tests +guard the file names, the substitutions, the ADR-009 plugin gating on the +routes, the use of global CSS classes over per-page input styling, graceful +skipping when there is no frontend tree, and the no-clobber behaviour. + +All generation runs against tmp_path. The real frontend/src tree is never +touched. +""" + +from pathlib import Path + +import pytest + +from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError + + +def _make_dirs(tmp_path: Path): + """Create a tmp plugins dir and a tmp frontend/src tree. + + Returns (plugins_dir, frontend_dir). + """ + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + frontend_dir = tmp_path / 'frontend' / 'src' + frontend_dir.mkdir(parents=True) + return plugins_dir, frontend_dir + + +def test_frontend_files_generated_with_correct_names(tmp_path): + """Views, route file, and api snippet appear with the expected names.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + target = scaffold_plugin( + name='widgets', + description='Test widgets plugin', + plugins_dir=plugins_dir, + frontend_dir=frontend_dir, + ) + + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists() + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue').exists() + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue').exists() + assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() + # paste-in snippet lands in the plugin dir, not the frontend tree + assert (target / 'frontend-api-snippet.js').exists() + + +def test_frontend_substitutions_applied(tmp_path): + """Name and Name placeholders are substituted, none left raw.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() + assert "import('../../views/widgets/WidgetsList.vue')" in route + assert "import('../../views/widgets/WidgetsForm.vue')" in route + assert "import('../../views/widgets/WidgetsDetail.vue')" in route + + list_view = (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').read_text() + assert 'widgetsApi' in list_view + assert "api.get('/widgets'" in list_view + # no unresolved template placeholders for our known substitution keys + assert '${name}' not in list_view + assert '${Name}' not in list_view + + +def test_route_file_has_plugin_gating_and_requiresauth(tmp_path): + """Every route carries meta.plugin; form routes add requiresAuth.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() + + # ADR-009 gating on every route + assert route.count("plugin: 'widgets'") == 4 + # form routes (new + edit) require auth; list + detail do not + assert route.count('requiresAuth: true') == 2 + assert "path: 'widgets/new'" in route + assert "path: 'widgets/:id/edit'" in route + + +def test_views_use_global_classes_not_custom_input_css(tmp_path): + """Views lean on global .filters / .form-control / .card classes and never + redefine input styling in a scoped block.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + views = frontend_dir / 'views' / 'widgets' + list_view = (views / 'WidgetsList.vue').read_text() + form_view = (views / 'WidgetsForm.vue').read_text() + + assert 'class="filters"' in list_view + assert 'class="form-control"' in list_view + assert 'class="card"' in list_view + assert 'class="form-control"' in form_view + + # per-page input styling is forbidden: no scoped rule targets .form-control + # on its own (a descendant layout helper like `.filters .form-control` is ok) + import re + for view in (list_view, form_view): + assert not re.search(r'(?m)^\s*\.form-control\s*\{', view) + + +def test_default_frontend_dir_resolves_relative_to_plugins(tmp_path): + """With no frontend_dir arg, views land in /../frontend/src.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + scaffold_plugin('widgets', 'Test', plugins_dir) + + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists() + assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() + + +def test_skip_when_no_frontend_dir(tmp_path): + """No frontend tree: views and route are skipped, backend still generated. + + The api snippet is still emitted because it is a plugin-directory artifact. + """ + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + missing_frontend = tmp_path / 'nowhere' / 'src' + + target = scaffold_plugin( + name='widgets', + description='Test', + plugins_dir=plugins_dir, + frontend_dir=missing_frontend, + ) + + # backend skeleton is intact + assert (target / 'manifest.json').exists() + assert (target / 'plugin.py').exists() + # frontend views and route were skipped + assert not missing_frontend.exists() + # snippet still lands in the plugin dir + assert (target / 'frontend-api-snippet.js').exists() + + +def test_frontend_can_be_disabled(tmp_path): + """frontend=False generates neither views, route, nor snippet.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + target = scaffold_plugin( + name='widgets', + description='Test', + plugins_dir=plugins_dir, + frontend_dir=frontend_dir, + frontend=False, + ) + + assert not (frontend_dir / 'views' / 'widgets').exists() + assert not (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() + assert not (target / 'frontend-api-snippet.js').exists() + + +def test_no_clobber_without_overwrite(tmp_path): + """An existing frontend view is left untouched unless overwrite is set.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + # author already hand-edited a view before re-scaffolding + views_dir = frontend_dir / 'views' / 'widgets' + views_dir.mkdir(parents=True) + existing = views_dir / 'WidgetsList.vue' + existing.write_text('SENTINEL do not clobber') + + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + # untouched without overwrite + assert existing.read_text() == 'SENTINEL do not clobber' + + +def test_overwrite_replaces_existing_frontend(tmp_path): + """overwrite=True regenerates an existing frontend view.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + views_dir = frontend_dir / 'views' / 'widgets' + views_dir.mkdir(parents=True) + existing = views_dir / 'WidgetsList.vue' + existing.write_text('SENTINEL do not clobber') + + # plugin dir must also exist to reach overwrite path + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + scaffold_plugin( + 'widgets', 'Test', plugins_dir, + frontend_dir=frontend_dir, overwrite=True, + ) + + assert existing.read_text() != 'SENTINEL do not clobber' + assert 'widgetsApi' in existing.read_text() + + +def test_route_references_only_existing_views(tmp_path): + """Every view lazy-imported by the route file exists on disk. + + Guards the write-views-before-route ordering: a route pointing at a missing + view crashes the Vite dev server. + """ + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() + import re + imported = re.findall(r"import\('(\.\./\.\./views/[^']+)'\)", route) + assert imported + for rel in imported: + # rel is relative to router/routes/; resolve against that dir + resolved = (frontend_dir / 'router' / 'routes' / rel).resolve() + assert resolved.exists(), f'route imports missing view: {rel}' + + +def test_generated_frontend_is_ascii(tmp_path): + """Generated frontend files contain plain ASCII only.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + target = scaffold_plugin( + 'widgets', 'Test', plugins_dir, frontend_dir=frontend_dir, + ) + + generated = [ + frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue', + frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue', + frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue', + frontend_dir / 'router' / 'routes' / 'widgets.js', + target / 'frontend-api-snippet.js', + ] + for path in generated: + text = path.read_text() + text.encode('ascii') # raises if any non-ASCII slipped in + + +def test_frontend_dir_not_created_when_absent(tmp_path): + """Scaffolder does not fabricate a frontend tree that was not there.""" + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + + # no frontend dir exists; default resolves to /frontend/src (absent) + scaffold_plugin('widgets', 'Test', plugins_dir) + + assert not (tmp_path / 'frontend').exists() + + +def test_scaffold_still_raises_on_existing_plugin(tmp_path): + """Frontend generation does not weaken the plugin-dir overwrite guard.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'first', plugins_dir, frontend_dir=frontend_dir) + + with pytest.raises(ScaffoldError, match='already exists'): + scaffold_plugin( + 'widgets', 'second', plugins_dir, frontend_dir=frontend_dir, + ) diff --git a/tests/test_plugins/__init__.py b/tests/test_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_plugins/test_dualpath_single.py b/tests/test_plugins/test_dualpath_single.py new file mode 100644 index 00000000..551d4987 --- /dev/null +++ b/tests/test_plugins/test_dualpath_single.py @@ -0,0 +1,163 @@ +"""Dualpath single-machine collapse (site setting dualpath_single_machine). + +A Dualpath pair is one physical dual-bay machine recorded as two asset rows. +With the setting on (default), the machines list, dashboard machine count, and +the floor map collapse the pair to the PRIMARY bay (lower natural-sort +assetnumber) and annotate it with its hidden partner. With the setting off, +both bays list and count separately. The data model always keeps both rows. + +Settings are cached, so each test invalidates the cache after seeding. +""" + +from shopdb.extensions import db as _db +from shopdb.core.models import Asset, AssetType, Setting +from shopdb.core.models.relationship import RelationshipType, AssetRelationship +from shopdb.core.api.settings import invalidate_settings_cache + +from plugins.machines.models import Machine + + +def _seed_dualpath_pair(): + """Create a 'machine' AssetType, two machine assets (2007, 2008) placed on + the map, and an active Dualpath relationship between them. 2007 is PRIMARY + (lower natural-sort). Returns (primary_machine, secondary_machine).""" + machinetype = AssetType(assettype='machine') + _db.session.add(machinetype) + _db.session.flush() + + dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False) + _db.session.add(dualpath) + _db.session.flush() + + primary_asset = Asset(assetnumber='2007', assettypeid=machinetype.assettypeid, + mapx=100, mapy=100) + secondary_asset = Asset(assetnumber='2008', assettypeid=machinetype.assettypeid, + mapx=110, mapy=110) + _db.session.add_all([primary_asset, secondary_asset]) + _db.session.flush() + + primary_machine = Machine(assetid=primary_asset.assetid) + secondary_machine = Machine(assetid=secondary_asset.assetid) + _db.session.add_all([primary_machine, secondary_machine]) + _db.session.flush() + + _db.session.add(AssetRelationship( + sourceassetid=primary_asset.assetid, + targetassetid=secondary_asset.assetid, + relationshiptypeid=dualpath.relationshiptypeid, + )) + _db.session.commit() + return primary_machine, secondary_machine + + +def _set_toggle(value): + Setting.set('dualpath_single_machine', value, valuetype='boolean', + category='site') + invalidate_settings_cache() + + +# --------------------------------------------------------------------------- +# Setting ON (default): pair collapses to the primary bay +# --------------------------------------------------------------------------- + +def test_list_collapses_secondary_and_annotates_primary(client, db): + invalidate_settings_cache() # default on (no row) + _seed_dualpath_pair() + + resp = client.get('/api/machines') + assert resp.status_code == 200, resp.get_json() + payload = resp.get_json() + items = payload['data'] + numbers = [i['assetnumber'] for i in items] + + # secondary bay (2008) is hidden; total reflects the collapse + assert numbers == ['2007'] + assert payload['meta']['pagination']['total'] == 1 + + # the visible primary carries its partner + partner = items[0]['dualpathpartner'] + assert partner is not None + assert partner['assetnumber'] == '2008' + assert partner['assetid'] is not None + assert partner['machineid'] is not None + + +def test_dashboard_machine_count_collapses(client, db): + invalidate_settings_cache() + _seed_dualpath_pair() + + resp = client.get('/api/dashboard') + assert resp.status_code == 200, resp.get_json() + counts = resp.get_json()['data'] + # two machine rows, one physical machine + assert counts['totalmachines'] == 1 + assert counts['counts']['machines'] == 1 + + +def test_map_excludes_secondary_marker(client, db): + invalidate_settings_cache() + _seed_dualpath_pair() + + resp = client.get('/api/assets/map') + assert resp.status_code == 200, resp.get_json() + assets = resp.get_json()['data']['assets'] + numbers = [a['assetnumber'] for a in assets] + + assert '2007' in numbers + assert '2008' not in numbers + primary = next(a for a in assets if a['assetnumber'] == '2007') + assert primary['dualpathpartner'] == '2008' + + +def test_detail_banner_partner_shown_regardless_of_toggle(client, db): + invalidate_settings_cache() + primary_machine, secondary_machine = _seed_dualpath_pair() + + # banner data is present on BOTH bays even with the toggle off + _set_toggle(False) + for machine_id, expected in ( + (primary_machine.machineid, '2008'), + (secondary_machine.machineid, '2007'), + ): + resp = client.get(f'/api/machines/{machine_id}') + assert resp.status_code == 200, resp.get_json() + partner = resp.get_json()['data']['dualpathpartner'] + assert partner is not None + assert partner['assetnumber'] == expected + + +# --------------------------------------------------------------------------- +# Setting OFF: both bays list and count separately +# --------------------------------------------------------------------------- + +def test_list_shows_both_bays_when_disabled(client, db): + _seed_dualpath_pair() + _set_toggle(False) + + resp = client.get('/api/machines') + assert resp.status_code == 200, resp.get_json() + payload = resp.get_json() + numbers = sorted(i['assetnumber'] for i in payload['data']) + + assert numbers == ['2007', '2008'] + assert payload['meta']['pagination']['total'] == 2 + + +def test_dashboard_counts_both_bays_when_disabled(client, db): + _seed_dualpath_pair() + _set_toggle(False) + + resp = client.get('/api/dashboard') + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['totalmachines'] == 2 + + +def test_map_shows_both_bays_when_disabled(client, db): + _seed_dualpath_pair() + _set_toggle(False) + + resp = client.get('/api/assets/map') + assert resp.status_code == 200, resp.get_json() + numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']] + assert '2007' in numbers + assert '2008' in numbers diff --git a/tests/test_plugins/test_employee_photo.py b/tests/test_plugins/test_employee_photo.py new file mode 100644 index 00000000..7de0142a --- /dev/null +++ b/tests/test_plugins/test_employee_photo.py @@ -0,0 +1,212 @@ +"""Tests for self-hosted employee photo upload, replace, delete, serve, and the +shared photo-URL resolver used by both EmployeeDetail and the kiosk cards. + +Covers: admin-gated upload/delete, the public serve route, one-photo-per-person +replace semantics, external-mode 409s, and resolve_employee_photo_url in both +directory modes. The wjf_employees HR DB is not available under test, so external +resolution is exercised by passing the Picture value directly (no query).""" + +import io +import os + +from shopdb.core.models import Setting +from plugins.employees.models import DirectoryEmployee +from plugins.employees.api.routes import ( + resolve_employee_photo_url, + EMPLOYEE_PHOTO_URL_PREFIX, + EMPLOYEE_PHOTO_STATIC_PREFIX, +) + + +def _set_mode(db, mode): + row = Setting.query.filter_by(key='employee_directory_mode').first() + if row: + row.value = mode + else: + db.session.add(Setting(key='employee_directory_mode', value=mode, + valuetype='string', category='site')) + db.session.commit() + + +def _make_employee(db, sso=210000001): + emp = DirectoryEmployee(sso=sso, firstname='Test', lastname='Person') + db.session.add(emp) + db.session.commit() + return emp + + +# --- upload / replace / delete (self-hosted) -------------------------------- + +def test_upload_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot upload an employee photo.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + data = {'file': (io.BytesIO(b'bytes'), 'p.png')} + resp = client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=member_headers) + assert resp.status_code == 403 + assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN' + + +def test_upload_sets_photofilename_and_file_exists(client, db, auth_headers, app): + """Admin upload writes photofilename + photourl and lands the file on disk.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + payload = b'\x89PNG\r\n\x1a\nfake-png' + data = {'file': (io.BytesIO(payload), 'p.png')} + resp = client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + + filename = f'photo-{emp.sso}.png' + body = resp.get_json()['data'] + assert body['photofilename'] == filename + assert body['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}{filename}' + + refreshed = db.session.get(DirectoryEmployee, emp.sso) + assert refreshed.photofilename == filename + + path = os.path.join(app.instance_path, 'employeephotos', filename) + assert os.path.exists(path) + with open(path, 'rb') as handle: + assert handle.read() == payload + + +def test_upload_rejects_invalid_extension(client, db, auth_headers): + """A disallowed file extension is rejected.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + data = {'file': (io.BytesIO(b'MZ'), 'p.exe')} + resp = client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR' + + +def test_upload_missing_employee_is_404(client, db, auth_headers): + """Uploading to a nonexistent SSO is a 404.""" + _set_mode(db, 'selfhosted') + data = {'file': (io.BytesIO(b'x'), 'p.png')} + resp = client.post('/api/employees/999999/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 404 + + +def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app): + """Re-upload with a different extension deletes the prior file.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + first = {'file': (io.BytesIO(b'first'), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=first, + content_type='multipart/form-data', headers=auth_headers) + oldpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png') + assert os.path.exists(oldpath) + + second = {'file': (io.BytesIO(b'second'), 'p.jpg')} + resp = client.post(f'/api/employees/{emp.sso}/photo', data=second, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 200 + + assert not os.path.exists(oldpath) + newpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.jpg') + assert os.path.exists(newpath) + refreshed = db.session.get(DirectoryEmployee, emp.sso) + assert refreshed.photofilename == f'photo-{emp.sso}.jpg' + + +def test_serve_returns_bytes(client, db, auth_headers): + """The public serve route returns the uploaded bytes without auth.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + payload = b'\x89PNG\r\n\x1a\nserved' + data = {'file': (io.BytesIO(payload), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + + served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png') + assert served.status_code == 200 + assert served.get_data() == payload + + +def test_delete_clears_field_and_removes_file(client, db, auth_headers, app): + """Delete clears photofilename and removes the uploaded file.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + data = {'file': (io.BytesIO(b'bytes'), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + path = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png') + assert os.path.exists(path) + + resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['photourl'] is None + assert not os.path.exists(path) + + refreshed = db.session.get(DirectoryEmployee, emp.sso) + assert refreshed.photofilename is None + + +def test_delete_forbidden_for_non_admin(client, db, member_headers): + """A role-less authenticated user cannot delete an employee photo.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=member_headers) + assert resp.status_code == 403 + + +# --- external-mode 409 ------------------------------------------------------ + +def test_upload_conflict_in_external_mode(client, db, auth_headers): + """Upload is a 409 when the directory is external (HR owns the photo).""" + _set_mode(db, 'external') + data = {'file': (io.BytesIO(b'x'), 'p.png')} + resp = client.post('/api/employees/210000001/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resp.status_code == 409 + assert resp.get_json()['data']['error']['code'] == 'CONFLICT' + + +def test_delete_conflict_in_external_mode(client, db, auth_headers): + """Delete is a 409 when the directory is external.""" + _set_mode(db, 'external') + resp = client.delete('/api/employees/210000001/photo', headers=auth_headers) + assert resp.status_code == 409 + + +# --- resolver --------------------------------------------------------------- + +def test_resolver_selfhosted_returns_upload_url_or_none(client, db, auth_headers): + """Self-hosted: served upload URL when a photo exists, else None.""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + assert resolve_employee_photo_url(emp.sso) is None + + data = {'file': (io.BytesIO(b'x'), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + assert resolve_employee_photo_url(emp.sso) == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png' + + +def test_resolver_external_prefixes_relative_and_passes_urls(db): + """External: relative HR paths get the static prefix; absolute URLs pass through.""" + _set_mode(db, 'external') + assert resolve_employee_photo_url(210000001, 'Support/210000001.png') == \ + f'{EMPLOYEE_PHOTO_STATIC_PREFIX}Support/210000001.png' + assert resolve_employee_photo_url(210000001, 'https://hr.example.net/p.png') == \ + 'https://hr.example.net/p.png' + assert resolve_employee_photo_url(210000001, None) is None + assert resolve_employee_photo_url(None) is None + + +def test_lookup_includes_photourl(client, db, auth_headers): + """The lookup serializer carries the resolved photourl (self-hosted).""" + _set_mode(db, 'selfhosted') + emp = _make_employee(db) + data = {'file': (io.BytesIO(b'x'), 'p.png')} + client.post(f'/api/employees/{emp.sso}/photo', data=data, + content_type='multipart/form-data', headers=auth_headers) + + resp = client.get(f'/api/employees/lookup/{emp.sso}') + assert resp.status_code == 200 + assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png' diff --git a/tests/test_plugins/test_geenforce_compliance.py b/tests/test_plugins/test_geenforce_compliance.py new file mode 100644 index 00000000..f4dc75cc --- /dev/null +++ b/tests/test_plugins/test_geenforce_compliance.py @@ -0,0 +1,166 @@ +"""GE-Enforce compliance: fleet-install coverage per app-linked entry. + +Compliance pairs each app-linked manifest entry with collected PC data +(ComputerInstalledApp) to answer 'how many PCs have this app, and how many on +the expected version'. Only entries carrying a curated appid appear; unlinked +entries are skipped. FileVersion detection supplies the expected-version target; +every other detection method yields a null target (no meaningful match). When +the optional computers plugin is absent, counts degrade to null gracefully. +""" + +from shopdb.core.models import Asset, AssetType, Application + +from plugins.geenforce import service + + +def _seed_app(db, appname): + app_row = Application(appname=appname) + db.session.add(app_row) + db.session.commit() + return app_row.appid + + +def _seed_pc_with_app(db, hostname, appid, installedversion, isactive=True): + """Create a Computer + one ComputerInstalledApp row for it.""" + from plugins.computers.models import Computer, ComputerInstalledApp + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber=f'PC-{hostname}', assettypeid=atype.assettypeid) + db.session.add(asset) + db.session.flush() + comp = Computer(assetid=asset.assetid, hostname=hostname) + db.session.add(comp) + db.session.flush() + link = ComputerInstalledApp(computerid=comp.computerid, appid=appid, + installedversion=installedversion, + isactive=isactive) + db.session.add(link) + db.session.commit() + return comp + + +def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'): + resp = client.post('/api/geenforce/scopes', + json={'scopename': name, 'phase': 'runtime'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['scopeid'] + + +def _add_entry(client, auth_headers, scopeid, entry): + resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json=entry, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['entryid'] + + +def test_compliance_only_lists_app_linked_entries(client, db, auth_headers): + appid = _seed_app(db, 'PC-DMIS') + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe', + 'DetectionValue': '2019 R1'}) + # An unlinked entry (no appid) must NOT appear. + _add_entry(client, auth_headers, scopeid, + {'Name': 'Unlinked', 'Type': 'MSI', 'Installer': 'apps/x.msi'}) + + data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data'] + assert data['computersplugin'] is True + assert [r['entryname'] for r in data['rows']] == ['PC-DMIS 2019'] + row = data['rows'][0] + assert row['appid'] == appid + assert row['appname'] == 'PC-DMIS' + assert row['expectedversion'] == '2019 R1' + + +def test_compliance_counts_active_installs_and_version_match(client, db, + auth_headers): + appid = _seed_app(db, 'PC-DMIS') + # Two PCs on the expected version, one on a different version, one inactive. + _seed_pc_with_app(db, 'CMM01', appid, '2019 R1') + _seed_pc_with_app(db, 'CMM02', appid, '2019 R1') + _seed_pc_with_app(db, 'CMM03', appid, '2016 R2') + _seed_pc_with_app(db, 'CMM04', appid, '2019 R1', isactive=False) + + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe', + 'DetectionValue': '2019 R1'}) + + row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data']['rows'][0] + # Inactive row excluded: 3 active installs, 2 on the expected version. + assert row['installedcount'] == 3 + assert row['versionmatchcount'] == 2 + assert '2 of 3' in row['coveragenote'] + + +def test_compliance_non_fileversion_has_null_version_target(client, db, + auth_headers): + appid = _seed_app(db, 'eDNC') + _seed_pc_with_app(db, 'DNC01', appid, '5.0') + scopeid = _create_scope(client, auth_headers) + # Registry detection is not a version string -> no expected-version target. + _add_entry(client, auth_headers, scopeid, + {'Name': 'eDNC', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'Registry', + 'DetectionPath': 'HKLM\\Software\\eDNC'}) + + row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data']['rows'][0] + assert row['expectedversion'] is None + assert row['installedcount'] == 1 + assert row['versionmatchcount'] is None + assert 'no version target' in row['coveragenote'] + + +def test_compliance_not_installed_anywhere(client, db, auth_headers): + appid = _seed_app(db, 'Orphan') + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, + {'Name': 'Orphan', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\o.exe', + 'DetectionValue': '1.0'}) + + row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data']['rows'][0] + assert row['installedcount'] == 0 + assert row['versionmatchcount'] == 0 + assert row['coveragenote'] == 'not installed on any collected PC' + + +def test_compliance_scope_not_found(client, db, auth_headers): + resp = client.get('/api/geenforce/scopes/999999/compliance', + headers=auth_headers) + assert resp.status_code == 404 + + +def test_compliance_requires_manage_permission(client, db, member_headers): + resp = client.get('/api/geenforce/scopes/1/compliance', + headers=member_headers) + assert resp.status_code == 403 + + +def test_compliance_graceful_without_computers_plugin(client, db, auth_headers, + monkeypatch): + appid = _seed_app(db, 'PC-DMIS') + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid, + 'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe', + 'DetectionValue': '2019 R1'}) + monkeypatch.setattr(service, '_installed_app_model', lambda: None) + + data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance', + headers=auth_headers).get_json()['data'] + assert data['computersplugin'] is False + row = data['rows'][0] + assert row['installedcount'] is None + assert row['versionmatchcount'] is None + assert 'computers plugin not installed' in row['coveragenote'] diff --git a/tests/test_plugins/test_geenforce_crud.py b/tests/test_plugins/test_geenforce_crud.py new file mode 100644 index 00000000..4448902c --- /dev/null +++ b/tests/test_plugins/test_geenforce_crud.py @@ -0,0 +1,278 @@ +"""GE-Enforce P2 admin CRUD: scopes, entries, reorder, simulate, publish cycle. + +All admin endpoints are JWT + geenforce.manage/publish gated (the admin user +holds both). Entry payloads use the manifest Applications[] shape. +""" + + +def _create_scope(client, auth_headers, name='gea-shopfloor-test'): + resp = client.post('/api/geenforce/scopes', + json={'scopename': name, 'phase': 'runtime', + 'description': 'test scope'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['scopeid'] + + +def _add_entry(client, auth_headers, scopeid, entry): + resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json=entry, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['entryid'] + + +def test_scope_crud(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + + got = client.get(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers) + assert got.status_code == 200 + assert got.get_json()['data']['scopename'] == 'gea-shopfloor-test' + + upd = client.put(f'/api/geenforce/scopes/{scopeid}', + json={'description': 'updated', 'computertypeid': 3}, + headers=auth_headers) + assert upd.get_json()['data']['computertypeid'] == 3 + + dele = client.delete(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers) + assert dele.status_code == 200 + assert client.get(f'/api/geenforce/scopes/{scopeid}', + headers=auth_headers).status_code == 404 + + +def test_duplicate_scope_rejected(client, db, auth_headers): + _create_scope(client, auth_headers, 'gea-shopfloor-dup') + resp = client.post('/api/geenforce/scopes', + json={'scopename': 'gea-shopfloor-dup', 'phase': 'runtime'}, + headers=auth_headers) + assert resp.status_code == 400 + + +def test_entry_crud_and_reorder(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + a = _add_entry(client, auth_headers, scopeid, + {'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/a.msi'}) + b = _add_entry(client, auth_headers, scopeid, + {'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/b.ps1'}) + + # Update Alpha: change type + add detection. + upd = client.put(f'/api/geenforce/entries/{a}', + json={'Name': 'Alpha', 'Type': 'EXE', 'Installer': 'apps/a.exe', + 'DetectionMethod': 'FileVersion', + 'DetectionPath': 'C:\\a.exe', 'DetectionValue': '1.0'}, + headers=auth_headers) + assert upd.status_code == 200 + assert upd.get_json()['data']['Type'] == 'EXE' + assert upd.get_json()['data']['DetectionMethod'] == 'FileVersion' + + # Reorder: Beta first. + reo = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder', + json={'order': [b, a]}, headers=auth_headers) + assert reo.status_code == 200 + entries = client.get(f'/api/geenforce/scopes/{scopeid}', + headers=auth_headers).get_json()['data']['entries'] + assert [e['Name'] for e in entries] == ['Beta', 'Alpha'] + + # Delete Beta. + assert client.delete(f'/api/geenforce/entries/{b}', + headers=auth_headers).status_code == 200 + entries = client.get(f'/api/geenforce/scopes/{scopeid}', + headers=auth_headers).get_json()['data']['entries'] + assert [e['Name'] for e in entries] == ['Alpha'] + + +def test_entry_edit_preserves_full_fidelity(client, db, auth_headers): + """Editing an entry with the complete payload keeps InUseCheck ExePath + + timeout, LogFile, and preinstall flags (the UI now sends all of them).""" + scopeid = _create_scope(client, auth_headers) + rich = { + 'Name': 'PC-DMIS', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi', + 'LogFile': 'C:\\Logs\\pcdmis.log', + 'PreEnrollment': True, 'PCTypesStrict': True, + 'InUseCheck': {'Behavior': 'CloseAndReopen', 'Processes': [ + {'Name': 'PCDLRN', 'ExePath': 'C:\\PCDLRN.exe', + 'GracefulCloseTimeoutSec': 15}]}, + } + entryid = _add_entry(client, auth_headers, scopeid, rich) + + # Re-send the same full payload (what the editor now does) and confirm + # nothing is stripped. + upd = client.put(f'/api/geenforce/entries/{entryid}', json=rich, + headers=auth_headers) + assert upd.status_code == 200 + entry = upd.get_json()['data'] + assert entry['LogFile'] == 'C:\\Logs\\pcdmis.log' + assert entry['PreEnrollment'] is True + assert entry['PCTypesStrict'] is True + proc = entry['InUseCheck']['Processes'][0] + assert proc['ExePath'] == 'C:\\PCDLRN.exe' + assert proc['GracefulCloseTimeoutSec'] == 15 + + +def test_entry_curated_app_link(client, db, auth_headers): + """An entry can be linked to an existing Application; the link is shopdb + metadata and must NOT leak into the manifest JSON (parity/enforcement).""" + from shopdb.core.models import Application + app_row = Application(appname='eDNC (tracked)') + db.session.add(app_row) + db.session.commit() + appid = app_row.appid + + scopeid = _create_scope(client, auth_headers) + created = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': 'eDNC install', 'Type': 'MSI', + 'appid': appid}, headers=auth_headers) + assert created.status_code == 201, created.get_json() + data = created.get_json()['data'] + assert data['appid'] == appid + assert data['appname'] == 'eDNC (tracked)' + + # the link is NOT in the rendered manifest (would break the engine contract) + manifest = client.get(f'/api/geenforce/scopes/{scopeid}/preview', + headers=auth_headers).get_json()['data']['manifest'] + entry_json = manifest['Applications'][0] + assert 'appid' not in entry_json and 'appname' not in entry_json + + # unlink + updated = client.put(f"/api/geenforce/entries/{data['entryid']}", + json={'Name': 'eDNC install', 'Type': 'MSI', 'appid': None}, + headers=auth_headers) + assert updated.get_json()['data']['appid'] is None + + # picker lists the app + picker = client.get('/api/geenforce/applications', + headers=auth_headers).get_json()['data'] + assert any(a['appname'] == 'eDNC (tracked)' for a in picker) + + +def test_entry_nonnumeric_appid_ignored(client, db, auth_headers): + """A non-numeric appid must be ignored (link stays null), not 500. Matches + the _apply_app_link docstring contract.""" + scopeid = _create_scope(client, auth_headers) + created = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': 'x', 'Type': 'MSI', 'appid': 'notanumber'}, + headers=auth_headers) + assert created.status_code == 201, created.get_json() + assert created.get_json()['data']['appid'] is None + + +def test_invalid_entry_type_rejected(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': 'Bad', 'Type': 'NOTATYPE'}, + headers=auth_headers) + assert resp.status_code == 400 + + +def test_reorder_rejects_foreign_ids(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + a = _add_entry(client, auth_headers, scopeid, + {'Name': 'Alpha', 'Type': 'MSI'}) + resp = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder', + json={'order': [a, 9999]}, headers=auth_headers) + assert resp.status_code == 400 + + +def test_simulate_cmm_version_gate(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm') + _add_entry(client, auth_headers, scopeid, + {'Name': 'CLM (untagged)', 'Type': 'MSI', 'Installer': 'apps/clm.msi'}) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2016', 'Type': 'MSI', 'Installer': 'apps/16.msi', + '_CmmVersion': '2016'}) + _add_entry(client, auth_headers, scopeid, + {'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'Installer': 'apps/19.msi', + '_CmmVersion': '2019'}) + + sim = client.get(f'/api/geenforce/scopes/{scopeid}/simulate?cmmversion=2019', + headers=auth_headers) + assert sim.status_code == 200 + data = sim.get_json()['data'] + assert data['applied'] == ['CLM (untagged)', 'PC-DMIS 2019'] + filtered = {f['name']: f['filteredby'] for f in data['filtered']} + assert filtered['PC-DMIS 2016'] == ['_CmmVersion'] + + +def test_duplicate_entry_name_is_400_not_500(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + _add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'}) + dup = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': 'Alpha', 'Type': 'PS1'}, headers=auth_headers) + assert dup.status_code == 400 + + +def test_simulate_pctypesstrict_disables_alias(client, db, auth_headers): + """A collections-only strict entry must NOT match a nocollections PC via the + shared Standard alias group. PCTypesStrict is preinstall-only, so the scope + must be the preinstall phase (the runtime engine ignores strict).""" + resp = client.post('/api/geenforce/scopes', + json={'scopename': 'preinstall', 'phase': 'preinstall'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + scopeid = resp.get_json()['data']['scopeid'] + _add_entry(client, auth_headers, scopeid, + {'Name': 'UDC (strict)', 'Type': 'EXE', 'Installer': 'apps/udc.exe', + 'PCTypes': ['gea-shopfloor-collections'], 'PCTypesStrict': True}) + _add_entry(client, auth_headers, scopeid, + {'Name': 'UDC (loose)', 'Type': 'EXE', 'Installer': 'apps/udc.exe', + 'PCTypes': ['gea-shopfloor-collections']}) + + sim = client.get( + f'/api/geenforce/scopes/{scopeid}/simulate?pctype=gea-shopfloor-nocollections', + headers=auth_headers).get_json()['data'] + # loose entry matches via the Standard alias group; strict entry does not. + assert sim['applied'] == ['UDC (loose)'] + filtered = {f['name']: f['filteredby'] for f in sim['filtered']} + assert filtered['UDC (strict)'] == ['PCTypes'] + + +def test_simulate_machine_number_gate(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-collections') + _add_entry(client, auth_headers, scopeid, + {'Name': 'Okuma cred', 'Type': 'PS1', 'Script': 'scripts/o.ps1', + 'TargetMachineNumbers': ['3201', '3202']}) + _add_entry(client, auth_headers, scopeid, + {'Name': 'Everyone', 'Type': 'PS1', 'Script': 'scripts/e.ps1'}) + + match = client.get( + f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=3201', + headers=auth_headers).get_json()['data'] + assert set(match['applied']) == {'Okuma cred', 'Everyone'} + + nomatch = client.get( + f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=9999', + headers=auth_headers).get_json()['data'] + assert nomatch['applied'] == ['Everyone'] + + +def test_publish_versions_rollback(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm') + _add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'}) + + pub1 = client.post(f'/api/geenforce/scopes/{scopeid}/publish', + json={'notes': 'v1'}, headers=auth_headers) + assert pub1.status_code == 201 + assert pub1.get_json()['data']['versionnumber'] == 1 + + _add_entry(client, auth_headers, scopeid, {'Name': 'Beta', 'Type': 'PS1'}) + pub2 = client.post(f'/api/geenforce/scopes/{scopeid}/publish', + json={'notes': 'v2'}, headers=auth_headers) + assert pub2.get_json()['data']['versionnumber'] == 2 + + versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions', + headers=auth_headers).get_json()['data'] + assert [v['versionnumber'] for v in versions] == [2, 1] + assert versions[0]['iscurrent'] is True + + # v2 manifest has both entries. + v2doc = client.get(f'/api/geenforce/scopes/{scopeid}/versions/2', + headers=auth_headers).get_json()['data']['manifest'] + assert [e['Name'] for e in v2doc['Applications']] == ['Alpha', 'Beta'] + + # Rollback to v1. + rb = client.post(f'/api/geenforce/scopes/{scopeid}/rollback', + json={'versionnumber': 1}, headers=auth_headers) + assert rb.status_code == 200 + versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions', + headers=auth_headers).get_json()['data'] + current = next(v for v in versions if v['iscurrent']) + assert current['versionnumber'] == 1 diff --git a/tests/test_plugins/test_geenforce_ddl_parity.py b/tests/test_plugins/test_geenforce_ddl_parity.py new file mode 100644 index 00000000..47cd51b3 --- /dev/null +++ b/tests/test_plugins/test_geenforce_ddl_parity.py @@ -0,0 +1,134 @@ +"""GE-Enforce DDL parity gate: models match the hand-written Alembic baseline. + +The geenforce baseline (0001_geenforce_baseline.py) actually CREATES the plugin +tables (ADR-008 per-plugin chain), and it is hand-written - the models and the +migration can drift silently (a column added to a model but not the migration +ships a schema that upgrade() never builds, and vice versa). This guard builds +the schema two ways and diffs them column-by-column: + + 1. the SQLAlchemy models via metadata.create_all (desired shape), and + 2. the migration's upgrade() run against a fresh engine (shipped shape). + +Both go through the same SQLite dialect, so the reflected column types render +identically and the comparison stays low-flake. A drift (missing column, changed +type, changed nullability) fails here instead of on a real deploy insert. +""" + +import importlib.util +from pathlib import Path + +from sqlalchemy import create_engine, inspect +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +_BASELINE = (Path(__file__).resolve().parent.parent.parent / 'plugins' / + 'geenforce' / 'migrations' / 'versions' / + '0001_geenforce_baseline.py') + + +def _geenforce_models(): + from plugins.geenforce.models import manifest + return [ + manifest.ManifestScope, + manifest.ManifestEntry, + manifest.ManifestEntryPcType, + manifest.ManifestEntryHostname, + manifest.ManifestEntryMachineNumber, + manifest.ManifestInUseCheck, + manifest.ManifestInUseCheckProcess, + manifest.ManifestPublishedVersion, + manifest.ManifestPayload, + manifest.ManifestEnforcementReport, + manifest.ManifestEnforcementResult, + manifest.PcTypeAlias, + ] + + +def _model_schema(db, models): + """Build the geenforce tables from the models; return an inspector.""" + engine = create_engine('sqlite://') + db.metadata.create_all(engine, tables=[model.__table__ for model in models]) + return inspect(engine) + + +def _migration_schema(): + """Run the baseline migration's upgrade() on a fresh engine; return columns. + + Returns {tablename: {columnname: reflected-column-dict}}. Reflection happens + while the connection is open, so we materialize the dicts before it closes. + """ + spec = importlib.util.spec_from_file_location('geenforce_baseline', + str(_BASELINE)) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + + engine = create_engine('sqlite://') + schema = {} + with engine.connect() as connection: + operations = Operations(MigrationContext.configure(connection)) + operations._install_proxy() + try: + migration.upgrade() + finally: + operations._remove_proxy() + connection.commit() + inspector = inspect(connection) + for table in inspector.get_table_names(): + schema[table] = {column['name']: column + for column in inspector.get_columns(table)} + return schema + + +def _colkey(column): + """Normalize a reflected column to the facets we gate on.""" + return (str(column['type']), bool(column['nullable'])) + + +def test_models_match_migration_baseline(app, db): + """Every geenforce model table+column matches what the migration builds.""" + with app.app_context(): + models = _geenforce_models() + model_inspector = _model_schema(db, models) + migration_schema = _migration_schema() + + drift = [] + for model in models: + table = model.__tablename__ + if table not in migration_schema: + drift.append(f'{table}: table missing from migration') + continue + modelcols = {column['name']: column + for column in model_inspector.get_columns(table)} + migrationcols = migration_schema[table] + + missing = set(modelcols) - set(migrationcols) + extra = set(migrationcols) - set(modelcols) + for name in sorted(missing): + drift.append(f'{table}.{name}: in model, not in migration') + for name in sorted(extra): + drift.append(f'{table}.{name}: in migration, not in model') + for name in sorted(set(modelcols) & set(migrationcols)): + if _colkey(modelcols[name]) != _colkey(migrationcols[name]): + drift.append( + f'{table}.{name}: model {_colkey(modelcols[name])} != ' + f'migration {_colkey(migrationcols[name])}') + + assert not drift, 'model/migration DDL drift:\n' + '\n'.join(drift) + + +def test_curated_and_payload_columns_present_both_sides(app, db): + """Spot-guard the two real-work columns: manifestentries.appid and + manifestpayloads.payloadsha256 exist in BOTH the models and the migration + (they were added for the app-link + inline-payload features).""" + with app.app_context(): + models = _geenforce_models() + model_inspector = _model_schema(db, models) + migration_schema = _migration_schema() + + for table, column in (('manifestentries', 'appid'), + ('manifestpayloads', 'payloadsha256')): + modelcols = {c['name'] for c in model_inspector.get_columns(table)} + assert column in modelcols, f'{table}.{column} missing from models' + assert column in migration_schema[table], \ + f'{table}.{column} missing from migration' diff --git a/tests/test_plugins/test_geenforce_export.py b/tests/test_plugins/test_geenforce_export.py new file mode 100644 index 00000000..d020bb14 --- /dev/null +++ b/tests/test_plugins/test_geenforce_export.py @@ -0,0 +1,65 @@ +"""GE-Enforce export-to-share: config the share root, publish, write to share. + +This is the Milestone 1 loop - author + publish in shopdb, then export the +published JSON to the on-share manifest with a history backup, engine untouched. +""" + +import json +import os + +from plugins.geenforce import service + + +def _scope_with_entry(app, name='gea-shopfloor-cmm'): + with app.app_context(): + service.replace_scope_draft(name, 'runtime', { + 'Version': '2.6', + 'Applications': [{'Name': 'Alpha', 'Type': 'MSI', + 'Installer': 'apps/a.msi'}]}) + service.publish_scope(name, 'runtime', notes='v1') + scope = service.ManifestScope.query.filter_by(scopename=name).first() + scopeid = scope.scopeid + service.db.session.commit() + return scopeid + + +def test_export_requires_config(client, db, app, auth_headers): + scopeid = _scope_with_entry(app) + resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share', + headers=auth_headers) + assert resp.status_code == 400 # no share root configured + + +def test_config_roundtrip_and_export(client, db, app, auth_headers, tmp_path): + scopeid = _scope_with_entry(app) + + # Configure the share root. + put = client.put('/api/geenforce/config', + json={'shareroot': str(tmp_path)}, headers=auth_headers) + assert put.status_code == 200 + got = client.get('/api/geenforce/config', headers=auth_headers) + assert got.get_json()['data']['shareroot'] == str(tmp_path) + + # Export writes the published JSON to //manifest.json. + resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share', + headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + path = resp.get_json()['data']['path'] + assert os.path.isfile(path) + written = json.load(open(path)) + assert [e['Name'] for e in written['Applications']] == ['Alpha'] + + +def test_export_backs_up_existing(client, db, app, auth_headers, tmp_path): + scopeid = _scope_with_entry(app) + client.put('/api/geenforce/config', json={'shareroot': str(tmp_path)}, + headers=auth_headers) + # First export creates the file. + client.post(f'/api/geenforce/scopes/{scopeid}/export-share', + headers=auth_headers) + # Second export backs the old file into _meta/history. + client.post(f'/api/geenforce/scopes/{scopeid}/export-share', + headers=auth_headers) + historydir = tmp_path / '_meta' / 'history' + assert historydir.is_dir() + assert any(historydir.iterdir()) diff --git a/tests/test_plugins/test_geenforce_manifest.py b/tests/test_plugins/test_geenforce_manifest.py new file mode 100644 index 00000000..99e4efd5 --- /dev/null +++ b/tests/test_plugins/test_geenforce_manifest.py @@ -0,0 +1,152 @@ +"""GE-Enforce first slice: import a scope, publish it, serve it to a client. + +Exercises the vertical the plan calls the first slice (via a small synthetic +scope rather than the real gea-shopfloor-cmm): draft import -> publish -> +GET /api/geenforce/manifest with a geenforce.fetch service token; ETag/304; +draft edits never change the served bytes; publish + rollback change them. +""" + +import json + +import pytest + +from plugins.geenforce.models import ManifestScope +from plugins.geenforce import service + + +SCOPE = { + 'Version': '2.6', + 'Applications': [ + {'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi', + 'DetectionMethod': 'Registry', 'DetectionPath': 'HKLM:\\SOFTWARE\\Alpha'}, + {'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1', + 'DetectionMethod': 'Always'}, + ], +} + + +def _seed_and_publish(app, scopename='gea-shopfloor-cmm', manifest=None): + with app.app_context(): + service.replace_scope_draft(scopename, 'runtime', manifest or SCOPE) + service.publish_scope(scopename, 'runtime', notes='initial') + service.db.session.commit() + + +def _mint_fetch_token(client, auth_headers): + resp = client.post('/api/apitokens', + json={'name': 'geenforce svc', 'scopes': ['geenforce.fetch']}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['secret'] + + +def test_import_publish_serve(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert resp.status_code == 200, resp.data + body = json.loads(resp.data) + assert [e['Name'] for e in body['Applications']] == ['Alpha', 'Beta'] + assert resp.headers.get('ETag') + assert resp.headers.get('X-Manifest-Version') == '1' + + +def test_etag_304(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + first = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + etag = first.headers['ETag'] + again = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret, 'If-None-Match': etag}) + assert again.status_code == 304 + + +def test_draft_edit_does_not_change_served_bytes(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + before = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}).data + + # Edit the DRAFT (add an entry) but do NOT publish. + changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [ + {'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]} + with app.app_context(): + service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed) + service.db.session.commit() + + after = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}).data + assert before == after # served bytes come from the published snapshot only + + +def test_publish_then_rollback(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + v1 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}).data + + changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [ + {'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]} + with app.app_context(): + service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed) + service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='add gamma') + service.db.session.commit() + + v2 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert v2.headers['X-Manifest-Version'] == '2' + assert b'Gamma' in v2.data + + with app.app_context(): + service.rollback_scope('gea-shopfloor-cmm', 'runtime', 1) + service.db.session.commit() + + rolled = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert rolled.data == v1 # byte-identical: published snapshots are frozen text + + +def test_unauthenticated_rejected(client, db, app): + _seed_and_publish(app) + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm') + assert resp.status_code == 401 + + +def test_wrong_scope_rejected(client, db, app, auth_headers): + _seed_and_publish(app) + resp = client.post('/api/apitokens', + json={'name': 'wrong', 'scopes': ['collector.ingest']}, + headers=auth_headers) + secret = resp.get_json()['data']['secret'] + manifest = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert manifest.status_code == 401 + + +def test_unpublished_scope_404(client, db, app, auth_headers): + with app.app_context(): + service.replace_scope_draft('gea-shopfloor-keyence', 'runtime', SCOPE) + service.db.session.commit() # draft only, never published + secret = _mint_fetch_token(client, auth_headers) + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-keyence', + headers={'X-API-Key': secret}) + assert resp.status_code == 404 + + +def test_admin_list_and_preview(client, db, app, auth_headers): + _seed_and_publish(app) + listing = client.get('/api/geenforce/scopes', headers=auth_headers) + assert listing.status_code == 200 + scopes = listing.get_json()['data'] + cmm = next(s for s in scopes if s['scopename'] == 'gea-shopfloor-cmm') + assert cmm['entrycount'] == 2 + assert cmm['publishedversion'] == 1 + + preview = client.get(f"/api/geenforce/scopes/{cmm['scopeid']}/preview", + headers=auth_headers) + assert preview.status_code == 200 + assert [e['Name'] for e in preview.get_json()['data']['manifest']['Applications']] \ + == ['Alpha', 'Beta'] diff --git a/tests/test_plugins/test_geenforce_payload.py b/tests/test_plugins/test_geenforce_payload.py new file mode 100644 index 00000000..ccdbc91f --- /dev/null +++ b/tests/test_plugins/test_geenforce_payload.py @@ -0,0 +1,125 @@ +"""GE-Enforce inline payloads: upload small scripts/configs into shopdb. + +An entry can carry one inline payload (<= 1 MB) stored as ManifestPayload bytes. +Upload computes a sha256, sets the entry's payloadsource='inline' + payloadref, +and enforces one payload row per entry (re-upload replaces). GET returns the +raw bytes. Upload needs geenforce.publish; GET needs geenforce.manage. appid and +the payload metadata are shopdb-only and never enter the served manifest JSON. +""" + +import hashlib +import io + +from plugins.geenforce.models import ManifestPayload + + +def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'): + resp = client.post('/api/geenforce/scopes', + json={'scopename': name, 'phase': 'runtime'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['scopeid'] + + +def _add_entry(client, auth_headers, scopeid, name='eDNC install'): + resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': name, 'Type': 'PS1', + 'Script': 'scripts/x.ps1'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['entryid'] + + +def _upload(client, headers, entryid, content, filename='config.reg', + content_type='multipart/form-data'): + return client.post( + f'/api/geenforce/entries/{entryid}/payload', + data={'file': (io.BytesIO(content), filename)}, + content_type=content_type, headers=headers) + + +def test_upload_stores_payload_and_links_entry(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + content = b'Windows Registry Editor Version 5.00\r\n' + + resp = _upload(client, auth_headers, entryid, content) + assert resp.status_code == 201, resp.get_json() + data = resp.get_json()['data'] + assert data['payloadsource'] == 'inline' + assert data['payloadref'] == 'config.reg' + assert data['payloadsha256'] == hashlib.sha256(content).hexdigest() + assert data['haspayload'] is True + + rows = ManifestPayload.query.filter_by(entryid=entryid).all() + assert len(rows) == 1 + assert rows[0].payloadbytes == content + + +def test_reupload_replaces_single_row(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + _upload(client, auth_headers, entryid, b'first') + newcontent = b'second version' + resp = _upload(client, auth_headers, entryid, newcontent) + assert resp.status_code == 201 + + rows = ManifestPayload.query.filter_by(entryid=entryid).all() + assert len(rows) == 1 + assert rows[0].payloadbytes == newcontent + assert rows[0].payloadsha256 == hashlib.sha256(newcontent).hexdigest() + + +def test_oversized_payload_rejected(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + toobig = b'x' * (1024 * 1024 + 1) + resp = _upload(client, auth_headers, entryid, toobig) + assert resp.status_code == 400 + assert ManifestPayload.query.filter_by(entryid=entryid).count() == 0 + + +def test_empty_and_missing_file_rejected(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + empty = _upload(client, auth_headers, entryid, b'') + assert empty.status_code == 400 + nofile = client.post(f'/api/geenforce/entries/{entryid}/payload', + data={}, content_type='multipart/form-data', + headers=auth_headers) + assert nofile.status_code == 400 + + +def test_download_returns_exact_bytes(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + content = b'\x00\x01binary payload\xff' + _upload(client, auth_headers, entryid, content, filename='blob.bin') + + resp = client.get(f'/api/geenforce/entries/{entryid}/payload', + headers=auth_headers) + assert resp.status_code == 200 + assert resp.data == content + assert 'blob.bin' in resp.headers['Content-Disposition'] + + +def test_download_404_when_no_payload(client, db, auth_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + resp = client.get(f'/api/geenforce/entries/{entryid}/payload', + headers=auth_headers) + assert resp.status_code == 404 + + +def test_upload_404_unknown_entry(client, db, auth_headers): + resp = _upload(client, auth_headers, 999999, b'data') + assert resp.status_code == 404 + + +def test_upload_requires_publish_permission(client, db, auth_headers, + member_headers): + scopeid = _create_scope(client, auth_headers) + entryid = _add_entry(client, auth_headers, scopeid) + # member (no permissions) cannot upload. + resp = _upload(client, member_headers, entryid, b'data') + assert resp.status_code == 403 diff --git a/tests/test_plugins/test_geenforce_reporting.py b/tests/test_plugins/test_geenforce_reporting.py new file mode 100644 index 00000000..4ed2d994 --- /dev/null +++ b/tests/test_plugins/test_geenforce_reporting.py @@ -0,0 +1,183 @@ +"""GE-Enforce observed-state reporting: PCs POST enforcement results. + +Covers the report ingest (applied version + per-entry self-heal outcomes), the +latest-per-host upsert, received-latest derivation (did the PC pick up the newest +published manifest?), status derivation (ok / selfhealed / failed), and the admin +fleet-compliance views. +""" + +import json + +from plugins.geenforce import service + + +SCOPE = { + 'Version': '2.6', + 'Applications': [ + {'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi'}, + {'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1'}, + ], +} + + +def _seed_and_publish(app, scopename='gea-shopfloor-cmm'): + with app.app_context(): + service.replace_scope_draft(scopename, 'runtime', SCOPE) + version = service.publish_scope(scopename, 'runtime', notes='v') + service.db.session.commit() + return version + + +def _token(client, auth_headers, scopes): + resp = client.post('/api/apitokens', + json={'name': 'svc', 'scopes': scopes}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['secret'] + + +def test_report_malformed_counts_is_400(client, db, app, auth_headers): + """A wrong JSON shape (counts as a string, results as a string) must be a + clean 400, not a 500 from a later .get()/iteration.""" + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + for bad in ({'counts': 'nope'}, {'results': 'nope'}): + body = {'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm'} + body.update(bad) + resp = client.post('/api/geenforce/report', json=body, + headers={'X-API-Key': secret}) + assert resp.status_code == 400, resp.get_json() + + +def test_report_recorded_and_listed(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + resp = client.post('/api/geenforce/report', json={ + 'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm', + 'appliedversion': 1, 'enforcerversion': '2.6', + 'counts': {'installed': 1, 'skipped': 1, 'failed': 0, 'filtered': 0}, + 'results': [ + {'name': 'Alpha', 'action': 'installed', 'selfhealed': True}, + {'name': 'Beta', 'action': 'skipped'}, + ], + }, headers={'X-API-Key': secret}) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['status'] == 'selfhealed' + + listing = client.get('/api/geenforce/reports', headers=auth_headers) + rows = listing.get_json()['data'] + assert len(rows) == 1 + row = rows[0] + assert row['hostname'] == 'WJCMM01' + assert row['appliedversion'] == 1 + assert row['latestversion'] == 1 + assert row['receivedlatest'] is True + assert row['installed'] == 1 and row['skipped'] == 1 + + +def test_received_latest_flips_when_new_version_published(client, db, app, + auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + client.post('/api/geenforce/report', + json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm', + 'appliedversion': 1, 'counts': {}}, + headers={'X-API-Key': secret}) + # Publish a newer version; the PC is now behind. + with app.app_context(): + service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='v2') + service.db.session.commit() + + row = client.get('/api/geenforce/reports?hostname=WJCMM02', + headers=auth_headers).get_json()['data'][0] + assert row['appliedversion'] == 1 + assert row['latestversion'] == 2 + assert row['receivedlatest'] is False + + +def test_latest_report_upserts_per_host(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + for installed in (0, 1): + client.post('/api/geenforce/report', + json={'hostname': 'WJCMM03', 'scopename': 'gea-shopfloor-cmm', + 'appliedversion': 1, + 'counts': {'installed': installed}}, + headers={'X-API-Key': secret}) + rows = client.get('/api/geenforce/reports?hostname=WJCMM03', + headers=auth_headers).get_json()['data'] + assert len(rows) == 1 # only the latest is current + assert rows[0]['installed'] == 1 + + +def test_hostname_case_variation_keeps_single_current(client, db, app, auth_headers): + """A PC reporting its hostname in different casing must not leave two + iscurrent rows (the demote is case-insensitive, matching the ilike read).""" + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + for host in ('WJCMM09', 'wjcmm09'): + client.post('/api/geenforce/report', + json={'hostname': host, 'scopename': 'gea-shopfloor-cmm', + 'appliedversion': 1, 'counts': {'installed': 1}}, + headers={'X-API-Key': secret}) + from plugins.geenforce.models import ManifestEnforcementReport + with app.app_context(): + current = ManifestEnforcementReport.query.filter( + ManifestEnforcementReport.hostname.ilike('WJCMM09'), + ManifestEnforcementReport.iscurrent == True).all() + assert len(current) == 1 + + +def test_status_failed_when_failures(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + resp = client.post('/api/geenforce/report', + json={'hostname': 'WJCMM04', 'scopename': 'gea-shopfloor-cmm', + 'counts': {'failed': 1}, + 'results': [{'name': 'Alpha', 'action': 'failed', + 'exitcode': 1603, + 'message': 'MSI 1603'}]}, + headers={'X-API-Key': secret}) + assert resp.get_json()['data']['status'] == 'failed' + + +def test_report_detail_shows_per_entry_results(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + post = client.post('/api/geenforce/report', + json={'hostname': 'WJCMM05', 'scopename': 'gea-shopfloor-cmm', + 'results': [ + {'name': 'Alpha', 'action': 'installed', + 'selfhealed': True}, + {'name': 'Beta', 'action': 'failed', + 'exitcode': 1, 'message': 'boom'}]}, + headers={'X-API-Key': secret}) + reportid = post.get_json()['data']['reportid'] + detail = client.get(f'/api/geenforce/reports/{reportid}', + headers=auth_headers).get_json()['data'] + results = {r['entryname']: r for r in detail['results']} + assert results['Alpha']['selfhealed'] is True + assert results['Beta']['action'] == 'failed' + assert results['Beta']['message'] == 'boom' + + +def test_report_requires_report_scope(client, db, app, auth_headers): + _seed_and_publish(app) + # A fetch-only token cannot report. + secret = _token(client, auth_headers, ['geenforce.fetch']) + resp = client.post('/api/geenforce/report', + json={'hostname': 'WJCMM06'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 401 + + +def test_report_unauthenticated_rejected(client, db, app): + resp = client.post('/api/geenforce/report', json={'hostname': 'WJCMM07'}) + assert resp.status_code == 401 + + +def test_report_missing_hostname_rejected(client, db, app, auth_headers): + secret = _token(client, auth_headers, ['geenforce.report']) + resp = client.post('/api/geenforce/report', json={'scopename': 'x'}, + headers={'X-API-Key': secret}) + assert resp.status_code == 400 diff --git a/tests/test_plugins/test_installed_apps.py b/tests/test_plugins/test_installed_apps.py new file mode 100644 index 00000000..094a1de7 --- /dev/null +++ b/tests/test_plugins/test_installed_apps.py @@ -0,0 +1,57 @@ +"""Installed-applications detail endpoint serializes correctly. + +Regression guard: GET /api/applications/machines/ serializes each +ComputerInstalledApp via its to_dict(). A missing to_dict raised a 500 that the +PCDetail page swallowed (v-if on a non-empty list), so real PCs silently showed +no installed software. This exercises the with-data path that the empty-list +case short-circuits past. +""" + +from shopdb.extensions import db as _db +from shopdb.core.models import Asset, AssetType, Application + +from plugins.computers.models import Computer, ComputerInstalledApp + + +def _seed_pc_with_app(client_db): + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer') + _db.session.add(atype) + _db.session.flush() + asset = Asset(assetnumber='PC-APPTEST', assettypeid=atype.assettypeid) + _db.session.add(asset) + _db.session.flush() + comp = Computer(assetid=asset.assetid, hostname='PC-APPTEST') + _db.session.add(comp) + _db.session.flush() + app = Application(appname='Test App') + _db.session.add(app) + _db.session.flush() + link = ComputerInstalledApp(computerid=comp.computerid, appid=app.appid, + installedversion='1.2.3') + _db.session.add(link) + _db.session.commit() + return comp + + +def test_installed_apps_endpoint_returns_data(client, db, auth_headers): + comp = _seed_pc_with_app(db) + resp = client.get(f'/api/applications/machines/{comp.computerid}', + headers=auth_headers) + assert resp.status_code == 200 + rows = resp.get_json()['data'] + assert len(rows) == 1 + row = rows[0] + assert row['appname'] == 'Test App' + assert row['installedversion'] == '1.2.3' + assert row['computerid'] == comp.computerid + + +def test_installed_app_to_dict_prefers_curated_version(db): + comp = _seed_pc_with_app(db) + link = ComputerInstalledApp.query.filter_by(computerid=comp.computerid).first() + data = link.to_dict() + assert data['appname'] == 'Test App' + assert data['installedversion'] == '1.2.3' + assert data['isactive'] is True diff --git a/tests/test_plugins/test_measuringtools.py b/tests/test_plugins/test_measuringtools.py new file mode 100644 index 00000000..1143a57e --- /dev/null +++ b/tests/test_plugins/test_measuringtools.py @@ -0,0 +1,475 @@ +"""Tests for the measuringtools plugin. + +The plugin ships default-disabled, so the shared session app does not register +its blueprint. This module builds its own app, registers the blueprint, runs the +plugin's on_install seeding, and restores the process-wide plugin_manager +singleton afterwards (create_app repoints it) so other test modules are +unaffected - the same snapshot/restore dance used by test_plugin_migrations. + +Coverage: derived calibration status (all four buckets + the 30-day boundary), +type CRUD with the in-use delete guard, tool create/update in one merged +payload, and the calibration report shape. +""" + +from datetime import date, timedelta + +import pytest +from werkzeug.security import generate_password_hash + +from shopdb import create_app +from shopdb.extensions import db as _db +from shopdb.plugins import plugin_manager +from plugins.measuringtools.models import ( + derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS) + + +# ============================================================================= +# Pure unit tests: derived status (no app needed) +# ============================================================================= + +def test_derive_status_unknown_when_no_date(): + assert derive_status(None) == 'unknown' + + +def test_derive_status_overdue_in_past(): + assert derive_status(date.today() - timedelta(days=1)) == 'overdue' + + +def test_derive_status_current_far_future(): + assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS + 1)) == 'current' + + +def test_derive_status_duesoon_within_window(): + assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS - 1)) == 'duesoon' + + +def test_derive_status_duesoon_on_boundary(): + """Exactly DUESOON_WINDOW_DAYS out is still 'due soon' (inclusive).""" + assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon' + + +def test_derive_status_duesoon_today(): + """Due today is not yet overdue - it falls in the due-soon window.""" + assert derive_status(date.today()) == 'duesoon' + + +# ============================================================================= +# API tests (self-contained app with the blueprint registered) +# ============================================================================= + +@pytest.fixture(scope='module') +def mt_app(): + """A testing app with the measuringtools blueprint registered + seeded.""" + saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) + application = create_app('testing') + + from plugins.measuringtools.plugin import MeasuringToolsPlugin + plugin = MeasuringToolsPlugin() + pm = application.extensions['plugin_manager'] + if plugin.meta.api_prefix not in pm._registered_prefixes: + pm._register_plugin_components(plugin) + + with application.app_context(): + _db.create_all() + _seed(application, plugin) + yield application + _db.session.remove() + _db.drop_all() + + (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) = saved + + +def _seed(application, plugin): + """Seed permissions, an admin, a status, and the plugin's own reference data.""" + from shopdb.core.models import Permission, User, Role, AssetStatus + Permission.seed() + role = Role(rolename='admin', description='Administrator') + _db.session.add(role) + _db.session.add(AssetStatus(status='In Use', description='In use')) + _db.session.flush() + user = User(username='testadmin', email='admin@test.local', + passwordhash=generate_password_hash('testpass')) + user.roles.append(role) + _db.session.add(user) + _db.session.commit() + # Seeds AssetType 'measuring_tool' + the starter tool types. + plugin.on_install(application) + + +@pytest.fixture +def client(mt_app): + return mt_app.test_client() + + +@pytest.fixture +def auth_headers(client): + response = client.post('/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}) + assert response.status_code == 200, response.get_json() + token = response.get_json()['data']['access_token'] + return {'Authorization': f'Bearer {token}'} + + +def _status_id(client): + return 1 # only status seeded + + +# -- Type CRUD + in-use guard ------------------------------------------------- + +def test_starter_types_seeded(client): + response = client.get('/api/measuringtools/types') + assert response.status_code == 200 + names = {t['name'] for t in response.get_json()['data']} + assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names + + +def test_type_create_requires_auth(client): + response = client.post('/api/measuringtools/types', json={'name': 'Nope'}) + assert response.status_code == 401 + + +def test_type_crud_lifecycle(client, auth_headers): + created = client.post('/api/measuringtools/types', headers=auth_headers, + json={'name': 'Pin Gage', 'description': 'Pin gage set', + 'color': '#123456'}) + assert created.status_code == 201, created.get_json() + type_id = created.get_json()['data']['measuringtooltypeid'] + + updated = client.put(f'/api/measuringtools/types/{type_id}', headers=auth_headers, + json={'description': 'Updated'}) + assert updated.status_code == 200 + assert updated.get_json()['data']['description'] == 'Updated' + + dup = client.post('/api/measuringtools/types', headers=auth_headers, + json={'name': 'Pin Gage'}) + assert dup.status_code == 409 + + deleted = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers) + assert deleted.status_code == 200 + + +def test_type_delete_blocked_when_in_use(client, auth_headers): + type_created = client.post('/api/measuringtools/types', headers=auth_headers, + json={'name': 'Depth Gage'}) + type_id = type_created.get_json()['data']['measuringtooltypeid'] + tool_created = client.post('/api/measuringtools', headers=auth_headers, + json={'assetnumber': 'MT-INUSE-1', + 'measuringtooltypeid': type_id, + 'statusid': _status_id(client)}) + assert tool_created.status_code == 201, tool_created.get_json() + + blocked = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers) + assert blocked.status_code == 409 + assert 'still use this type' in blocked.get_json()['data']['error']['message'] + + +# -- Tool create/update merged payload ---------------------------------------- + +def test_tool_create_and_get_merged(client, auth_headers): + caliper = client.get('/api/measuringtools/types').get_json()['data'] + caliper_id = next(t['measuringtooltypeid'] for t in caliper if t['name'] == 'Caliper') + + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-001', + 'name': 'Bench caliper', + 'serialnumber': 'SN-CAL-9', + 'gaugelabreference': 'GL-42', + 'measuringtooltypeid': caliper_id, + 'statusid': _status_id(client), + 'calibrationintervaldays': 365, + 'lastcalibrationdate': '2026-01-01', + 'nextcalibrationdate': '2027-01-01', + 'calibrationprovider': 'Metro Cal Lab', + }) + assert created.status_code == 201, created.get_json() + payload = created.get_json()['data'] + # Asset core fields at the top level, extension nested. + assert payload['assetnumber'] == 'MT-001' + assert payload['serialnumber'] == 'SN-CAL-9' + assert payload['gaugelabreference'] == 'GL-42' + ext = payload['measuringtool'] + assert ext['measuringtooltypename'] == 'Caliper' + assert ext['calibrationprovider'] == 'Metro Cal Lab' + assert ext['calibrationstatus'] == 'current' + tool_id = ext['measuringtoolid'] + + fetched = client.get(f'/api/measuringtools/{tool_id}') + assert fetched.status_code == 200 + assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id + + +def test_tool_payload_carries_location_code_and_name(mt_app, client, auth_headers): + """A tool at an operation location exposes locationcode (leading token) + and locationname so a printed label can encode the inspection operation.""" + with mt_app.app_context(): + from shopdb.core.models import Location + location = Location(locationname='0615 Blisk Inspection') + _db.session.add(location) + _db.session.commit() + location_id = location.locationid + + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-LOC-1', 'statusid': _status_id(client), + 'locationid': location_id, + }) + assert created.status_code == 201, created.get_json() + payload = created.get_json()['data'] + assert payload['locationname'] == '0615 Blisk Inspection' + assert payload['locationcode'] == '0615' + + +def test_tool_payload_location_code_none_when_unplaced(client, auth_headers): + """A tool with no location degrades gracefully: locationcode is None.""" + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-NOLOC-1', 'statusid': _status_id(client), + }) + assert created.status_code == 201, created.get_json() + assert created.get_json()['data']['locationcode'] is None + + +def test_tool_update_merged_payload(client, auth_headers): + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-UPD-1', 'statusid': _status_id(client), + 'nextcalibrationdate': '2030-01-01', + }) + tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] + + updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, json={ + 'name': 'Renamed tool', # asset core field + 'calibrationprovider': 'In-house', # extension field + 'nextcalibrationdate': str(date.today() - timedelta(days=5)), # -> overdue + }) + assert updated.status_code == 200, updated.get_json() + data = updated.get_json()['data'] + assert data['name'] == 'Renamed tool' + assert data['measuringtool']['calibrationprovider'] == 'In-house' + assert data['measuringtool']['calibrationstatus'] == 'overdue' + + +def test_tool_create_duplicate_assetnumber_conflicts(client, auth_headers): + body = {'assetnumber': 'MT-DUP', 'statusid': _status_id(client)} + first = client.post('/api/measuringtools', headers=auth_headers, json=body) + assert first.status_code == 201 + second = client.post('/api/measuringtools', headers=auth_headers, json=body) + assert second.status_code == 409 + + +def test_list_filter_by_calibrationstatus(client, auth_headers): + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client), + 'nextcalibrationdate': str(date.today() - timedelta(days=10)), + }) + response = client.get('/api/measuringtools?calibrationstatus=overdue') + assert response.status_code == 200 + rows = response.get_json()['data'] + assert rows, 'expected at least one overdue tool' + assert all(r['measuringtool']['calibrationstatus'] == 'overdue' for r in rows) + + +# -- Maintenance reference identifier ----------------------------------------- + +def test_maintenancereference_roundtrip(client, auth_headers): + """Maintenance reference persists on create and update.""" + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-MNT-1', 'statusid': _status_id(client), + 'maintenancereference': 'MNT-9', + }) + assert created.status_code == 201, created.get_json() + assert created.get_json()['data']['maintenancereference'] == 'MNT-9' + tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] + + updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, + json={'maintenancereference': 'MNT-10'}) + assert updated.status_code == 200 + assert updated.get_json()['data']['maintenancereference'] == 'MNT-10' + + +# -- Global search ------------------------------------------------------------ + +def _mt_search_hits(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + return [r for r in resp.get_json()['data']['results'] + if r.get('type') == 'measuring_tool'] + + +def test_search_finds_tool_by_assetnumber(mt_app, client, auth_headers, monkeypatch): + """A measuring tool appears in global search by asset number, routed right.""" + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-SEARCH-AN', 'statusid': _status_id(client)}) + pm = mt_app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + hits = _mt_search_hits(client, auth_headers, 'MT-SEARCH-AN') + assert hits + assert hits[0]['url'].startswith('/measuringtools/') + + +def test_search_finds_tool_by_gaugelabreference(mt_app, client, auth_headers, monkeypatch): + """A gage-tag lookup by gaugelabreference finds the tool.""" + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-SEARCH-GL', 'statusid': _status_id(client), + 'gaugelabreference': 'GLREF-778'}) + pm = mt_app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + hits = _mt_search_hits(client, auth_headers, 'GLREF-778') + assert hits + assert hits[0]['url'].startswith('/measuringtools/') + + +def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers, monkeypatch): + """search_measuring_tool_enabled=false hides measuring tools from search.""" + from shopdb.core.models import Setting + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-SEARCH-OFF', 'statusid': _status_id(client), + 'gaugelabreference': 'GLREF-OFF-1'}) + pm = mt_app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + Setting.set('search_measuring_tool_enabled', False, valuetype='boolean', + category='search') + try: + assert _mt_search_hits(client, auth_headers, 'MT-SEARCH-OFF') == [] + assert _mt_search_hits(client, auth_headers, 'GLREF-OFF-1') == [] + finally: + Setting.set('search_measuring_tool_enabled', True, valuetype='boolean', + category='search') + + +# -- Report shape ------------------------------------------------------------- + +def test_calibration_report_shape(client, auth_headers): + response = client.get('/api/measuringtools/report/calibration') + assert response.status_code == 200 + data = response.get_json()['data'] + assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'} + assert set(data['buckets']) == {'overdue', 'duesoon', 'current', 'unknown'} + # Buckets and counts agree. + for key, rows in data['buckets'].items(): + assert data['counts'][key] == len(rows) + assert 'statuscolors' in data + + +# -- Core integration: asset serialization ------------------------------------ + +def _caliper_id(client): + types = client.get('/api/measuringtools/types').get_json()['data'] + return next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper') + + +def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client, auth_headers): + """Asset.to_dict resolves the measuringtool extension (typedata + pluginid).""" + from shopdb.core.models import Asset + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-TD-1', 'statusid': _status_id(client), + 'measuringtooltypeid': _caliper_id(client), + }) + assert created.status_code == 201, created.get_json() + tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] + assetid = created.get_json()['data']['assetid'] + + with mt_app.app_context(): + asset = _db.session.get(Asset, assetid) + result = asset.to_dict(include_type_data=True) + assert result['pluginid'] == tool_id + assert result['typedata']['measuringtooltypename'] == 'Caliper' + assert result['typedata']['measuringtoolid'] == tool_id + + +# -- Core integration: shop-floor map ----------------------------------------- + +def test_map_lists_measuringtool_subtypes(client, auth_headers): + """The map filter dropdown carries a Measuring Tool subtype list with color.""" + response = client.get('/api/assets/map') + assert response.status_code == 200, response.get_json() + subtypes = response.get_json()['data']['filters']['subtypes'] + assert 'Measuring Tool' in subtypes + names = {s['name'] for s in subtypes['Measuring Tool']} + assert 'Caliper' in names + assert all('color' in s for s in subtypes['Measuring Tool']) + + +def test_map_honors_measuringtool_subtype_filter(client, auth_headers): + """?assettype=measuring_tool&subtype= returns only that subtype's tools.""" + types = client.get('/api/measuringtools/types').get_json()['data'] + caliper_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper') + micrometer_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Micrometer') + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-MAP-CAL', 'statusid': _status_id(client), + 'measuringtooltypeid': caliper_id, 'mapx': 10, 'mapy': 20}) + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-MAP-MIC', 'statusid': _status_id(client), + 'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40}) + + response = client.get( + f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}') + assert response.status_code == 200, response.get_json() + numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']} + assert 'MT-MAP-CAL' in numbers + assert 'MT-MAP-MIC' not in numbers + + +def test_map_item_carries_measuringtool_typedata(client, auth_headers): + """A mapped tool's item carries the extension typedata for marker coloring.""" + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client), + 'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66}) + response = client.get('/api/assets/map?assettype=measuring_tool') + item = next(a for a in response.get_json()['data']['assets'] + if a['assetnumber'] == 'MT-MAP-TD') + assert item['typedata']['measuringtooltypename'] == 'Caliper' + + +# -- Core integration: dashboard ---------------------------------------------- + +def test_dashboard_counts_include_measuringtools(client, auth_headers): + """Dashboard total and counts include active measuring tools.""" + before = client.get('/api/dashboard').get_json()['data'] + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-DASH-1', 'statusid': _status_id(client)}) + after = client.get('/api/dashboard').get_json()['data'] + assert after['counts']['measuringtools'] == before['counts']['measuringtools'] + 1 + assert after['totalmeasuringtool'] == before['totalmeasuringtool'] + 1 + assert after['counts']['total'] == before['counts']['total'] + 1 + + +# -- Map overlay endpoint (ADR-010) ------------------------------------------- + +def test_map_overlay_shape_and_derivation(client, auth_headers): + """map-overlay returns per-asset derived calibration status + color.""" + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-OVL-1', 'statusid': _status_id(client), + 'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue + assetid = created.get_json()['data']['assetid'] + + response = client.get('/api/measuringtools/map-overlay') + assert response.status_code == 200, response.get_json() + rows = response.get_json()['data'] + row = next(r for r in rows if r['assetid'] == assetid) + assert set(row) == {'assetid', 'calibrationstatus', 'statuscolor'} + assert row['calibrationstatus'] == 'overdue' + assert row['statuscolor'] == STATUS_COLORS['overdue'] + + +def test_map_overlay_excludes_inactive(client, auth_headers): + """A soft-deleted tool drops out of the overlay.""" + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-OVL-DEL', 'statusid': _status_id(client)}) + tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] + assetid = created.get_json()['data']['assetid'] + client.delete(f'/api/measuringtools/{tool_id}', headers=auth_headers) + + rows = client.get('/api/measuringtools/map-overlay').get_json()['data'] + assert all(r['assetid'] != assetid for r in rows) + + +# -- Presentation route token (ADR-010) --------------------------------------- + +def test_asset_presentation_route_token(): + """Presentation route links through the by-asset resolver (only {assetid}).""" + from plugins.measuringtools.plugin import MeasuringToolsPlugin + entries = MeasuringToolsPlugin().get_asset_presentation() + entry = next(e for e in entries if e['assettype'] == 'measuring_tool') + assert entry['route'] == '/measuringtools/by-asset/{assetid}' diff --git a/tests/test_plugins/test_model_image_in_detail.py b/tests/test_plugins/test_model_image_in_detail.py new file mode 100644 index 00000000..4b49f836 --- /dev/null +++ b/tests/test_plugins/test_model_image_in_detail.py @@ -0,0 +1,100 @@ +"""Vendor-model photo surfaces in asset-extension detail payloads. + +Machines, computers, and printers link a vendor Model via modelnumberid; when +that Model carries an imageurl, the extension dict in the GET detail payload +must expose it as 'imageurl' (the field name machines established, consumed by +the detail-page heroes and the machine badge). When no model is linked, the +field must be absent so the frontend v-if renders nothing. + +Network devices and measuring tools have no model link, so they are out of +scope here by design. +""" + +from shopdb.extensions import db as _db +from shopdb.core.models import Asset, AssetType, Model + +from plugins.machines.models import Machine +from plugins.computers.models import Computer +from plugins.printers.models import Printer + +IMAGE_URL = '/api/models/images/model-1.png' + + +def _seed_asset(assetnumber, assettype): + atype = AssetType.query.filter_by(assettype=assettype).first() + if not atype: + atype = AssetType(assettype=assettype) + _db.session.add(atype) + _db.session.flush() + asset = Asset(assetnumber=assetnumber, assettypeid=atype.assettypeid) + _db.session.add(asset) + _db.session.flush() + return asset + + +def _seed_model(imageurl=IMAGE_URL): + model = Model(modelnumber='IMG-MODEL-1', imageurl=imageurl) + _db.session.add(model) + _db.session.flush() + return model + + +def test_machine_detail_carries_model_imageurl(client, db): + asset = _seed_asset('2001', 'machine') + model = _seed_model() + machine = Machine(assetid=asset.assetid, modelnumberid=model.modelnumberid) + _db.session.add(machine) + _db.session.commit() + + resp = client.get(f'/api/machines/{machine.machineid}') + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['machine']['imageurl'] == IMAGE_URL + + +def test_computer_detail_carries_model_imageurl(client, db): + asset = _seed_asset('PC-2001', 'computer') + model = _seed_model() + computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid) + _db.session.add(computer) + _db.session.commit() + + resp = client.get(f'/api/computers/{computer.computerid}') + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['computer']['imageurl'] == IMAGE_URL + + +def test_printer_detail_carries_model_imageurl(client, db): + asset = _seed_asset('PR-2001', 'printer') + model = _seed_model() + printer = Printer(assetid=asset.assetid, modelnumberid=model.modelnumberid) + _db.session.add(printer) + _db.session.commit() + + resp = client.get(f'/api/printers/{printer.printerid}') + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()['data']['printer']['imageurl'] == IMAGE_URL + + +def test_detail_omits_imageurl_without_model(client, db): + """No model linked -> no imageurl key (frontend v-if shows nothing).""" + asset = _seed_asset('PR-2002', 'printer') + printer = Printer(assetid=asset.assetid) + _db.session.add(printer) + _db.session.commit() + + resp = client.get(f'/api/printers/{printer.printerid}') + assert resp.status_code == 200, resp.get_json() + assert 'imageurl' not in resp.get_json()['data']['printer'] + + +def test_detail_omits_imageurl_when_model_has_no_image(client, db): + """Model linked but no photo -> no imageurl key.""" + asset = _seed_asset('PC-2002', 'computer') + model = _seed_model(imageurl=None) + computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid) + _db.session.add(computer) + _db.session.commit() + + resp = client.get(f'/api/computers/{computer.computerid}') + assert resp.status_code == 200, resp.get_json() + assert 'imageurl' not in resp.get_json()['data']['computer'] diff --git a/tests/test_plugins/test_modelsupplies.py b/tests/test_plugins/test_modelsupplies.py new file mode 100644 index 00000000..f83d6e76 --- /dev/null +++ b/tests/test_plugins/test_modelsupplies.py @@ -0,0 +1,121 @@ +"""Tests for the model-supplies (toner part-number) management API.""" + +import pytest + + +@pytest.fixture +def model(db): + """A vendor + printer model to attach supplies to.""" + from shopdb.core.models import Vendor, Model + + vendor = Vendor(vendor='TestVendor') + db.session.add(vendor) + db.session.flush() + + model = Model(modelnumber='TestModel C999', vendorid=vendor.vendorid) + db.session.add(model) + db.session.commit() + return model + + +def test_supplies_meta_lists_allowed_values(client, db): + response = client.get('/api/printers/supplies/meta') + assert response.status_code == 200 + data = response.get_json()['data'] + assert 'toner' in data['supplytypes'] + assert 'black' in data['colors'] + assert 'metered' in data['capacitytiers'] + + +def test_create_and_list_model_supply(client, model, auth_headers): + create = client.post( + f'/api/printers/models/{model.modelnumberid}/supplies', + json={ + 'supplytype': 'toner', 'color': 'black', 'capacitytier': 'standard', + 'partnumber': 'W2020A', 'marketingname': '414A Black', 'pageyield': 2400, + }, + headers=auth_headers, + ) + assert create.status_code == 201 + + listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies') + assert listing.status_code == 200 + supplies = listing.get_json()['data']['supplies'] + assert len(supplies) == 1 + assert supplies[0]['partnumber'] == 'W2020A' + + +def test_duplicate_partnumber_rejected(client, model, auth_headers): + payload = {'partnumber': 'W2020A', 'color': 'black'} + client.post(f'/api/printers/models/{model.modelnumberid}/supplies', + json=payload, headers=auth_headers) + second = client.post(f'/api/printers/models/{model.modelnumberid}/supplies', + json=payload, headers=auth_headers) + assert second.status_code == 409 + + +def test_invalid_enum_rejected(client, model, auth_headers): + response = client.post( + f'/api/printers/models/{model.modelnumberid}/supplies', + json={'partnumber': 'X1', 'color': 'purple'}, + headers=auth_headers, + ) + assert response.status_code == 400 + + +def test_create_requires_auth(client, model): + response = client.post( + f'/api/printers/models/{model.modelnumberid}/supplies', + json={'partnumber': 'X1'}, + ) + assert response.status_code == 401 + + +def test_update_and_delete_supply(client, model, auth_headers): + created = client.post( + f'/api/printers/models/{model.modelnumberid}/supplies', + json={'partnumber': 'W2020A', 'color': 'black', 'capacitytier': 'standard'}, + headers=auth_headers, + ).get_json()['data'] + supplyid = created['modelsupplyid'] + + updated = client.put( + f'/api/printers/supplies/{supplyid}', + json={'capacitytier': 'high', 'marketingname': '414X Black'}, + headers=auth_headers, + ) + assert updated.status_code == 200 + assert updated.get_json()['data']['capacitytier'] == 'high' + + deleted = client.delete(f'/api/printers/supplies/{supplyid}', headers=auth_headers) + assert deleted.status_code == 200 + + listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies') + assert listing.get_json()['data']['supplies'] == [] + + +def test_listmodels_reports_supplycount(client, model, auth_headers): + client.post(f'/api/printers/models/{model.modelnumberid}/supplies', + json={'partnumber': 'W2020A', 'color': 'black'}, headers=auth_headers) + + response = client.get('/api/printers/models', query_string={'search': 'C999'}) + assert response.status_code == 200 + rows = response.get_json()['data'] + match = next(r for r in rows if r['modelnumberid'] == model.modelnumberid) + assert match['supplycount'] == 1 + + +def test_seed_supplies_corrected_data(app, db): + """The seed loads corrected part numbers (C405 colors, no B405 waste).""" + with app.app_context(): + from plugins.printers.services import seedsupplies, lookupsupplies + from shopdb.core.models import Model + + seedsupplies() + + c405 = Model.query.filter(Model.modelnumber.ilike('%C405%')).first() + yellow = lookupsupplies(c405.modelnumberid, 'yellow', 'toner') + assert any(s['partnumber'] == '106R03501' for s in yellow) + + b405 = Model.query.filter(Model.modelnumber.ilike('%B405%')).first() + assert lookupsupplies(b405.modelnumberid, 'none', 'waste') == [] diff --git a/tests/test_plugins/test_network_ipaddress.py b/tests/test_plugins/test_network_ipaddress.py new file mode 100644 index 00000000..1fd9fe80 --- /dev/null +++ b/tests/test_plugins/test_network_ipaddress.py @@ -0,0 +1,68 @@ +"""Network device create/update attaches a primary IP Communication. + +Classic network devices carry their IP in the communications table, and the +legacy import needs an HTTP path to set it. The network create/update routes +accept an `ipaddress` and materialize a primary Communication (mirroring the +printer route); GET surfaces it back. +""" + +import pytest + + +@pytest.fixture +def network_setup(db): + from shopdb.core.models import AssetType, CommunicationType + db.session.add(AssetType(assettype='network_device', pluginname='network', + tablename='networkdevices', description='Network devices')) + db.session.add(CommunicationType(comtype='IP', description='IP')) + db.session.commit() + + +def test_create_network_device_with_ipaddress(client, db, auth_headers, network_setup): + """POST /api/network with an ipaddress creates a primary Communication and + echoes the IP back.""" + from shopdb.core.models import Communication + resp = client.post('/api/network', json={ + 'assetnumber': 'SW-CORE-01', 'name': 'Core switch', + 'hostname': 'sw-core-01', 'ipaddress': '10.129.22.101', + }, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + data = resp.get_json()['data'] + assert data['ipaddress'] == '10.129.22.101' + + assetid = data['assetid'] + comm = Communication.query.filter_by(assetid=assetid, isprimary=True).first() + assert comm is not None and comm.ipaddress == '10.129.22.101' + + # GET surfaces it too. + got = client.get(f"/api/network/{data['networkdevice']['networkdeviceid']}", + headers=auth_headers) + assert got.get_json()['data']['ipaddress'] == '10.129.22.101' + + +def test_update_network_device_ipaddress_upserts(client, db, auth_headers, network_setup): + """PUT changes the IP in place without creating a second Communication.""" + from shopdb.core.models import Communication + created = client.post('/api/network', json={ + 'assetnumber': 'AP-01', 'name': 'AP', 'ipaddress': '10.0.0.5', + }, headers=auth_headers).get_json()['data'] + devid = created['networkdevice']['networkdeviceid'] + + updated = client.put(f'/api/network/{devid}', json={'ipaddress': '10.0.0.9'}, + headers=auth_headers) + assert updated.status_code == 200, updated.get_json() + assert updated.get_json()['data']['ipaddress'] == '10.0.0.9' + + comms = Communication.query.filter_by(assetid=created['assetid']).all() + assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9' + + +def test_seed_reference_data_creates_ip_commtype(app, db): + """The reference-data seed now creates the IP communication type the asset + import depends on.""" + from shopdb.core.models import CommunicationType + runner = app.test_cli_runner() + assert runner.invoke(args=['seed', 'reference-data']).exit_code in (0, None) + with app.app_context(): + assert CommunicationType.query.filter_by(comtype='IP').first() is not None + assert CommunicationType.query.count() >= 8 diff --git a/tests/test_plugins/test_pc_default_printer.py b/tests/test_plugins/test_pc_default_printer.py new file mode 100644 index 00000000..448c8409 --- /dev/null +++ b/tests/test_plugins/test_pc_default_printer.py @@ -0,0 +1,69 @@ +"""Tests for the per-PC default-printer endpoint (/api/printers/pc-default). + +Parity with classic apipcdefaultprinter.asp: the signed printer-installer EXE +preselects a PC's default printer by machine (asset) number. The link is a +`defaultprinter` asset relationship (PC asset -> printer asset). +""" + +import pytest + +from shopdb.core.models import Asset, AssetType, AssetRelationship, RelationshipType +from plugins.printers.models import Printer + + +@pytest.fixture +def scene(db): + """A PC asset, a printer asset, and the defaultprinter relationship type.""" + pc_type = AssetType(assettype='computer', pluginname='computers', tablename='computers') + pr_type = AssetType(assettype='printer', pluginname='printers', tablename='printers') + dp_type = RelationshipType(relationshiptype='defaultprinter', description='PC to default printer') + db.session.add_all([pc_type, pr_type, dp_type]) + db.session.flush() + + pc = Asset(assetnumber='3210', name='CSF04 PC', assettypeid=pc_type.assettypeid, isactive=True) + pr_asset = Asset(assetnumber='PRN-01', name='Materials HP', assettypeid=pr_type.assettypeid, isactive=True) + db.session.add_all([pc, pr_asset]) + db.session.flush() + + printer = Printer(assetid=pr_asset.assetid, windowsname='HP-CSF04-Materials') + db.session.add(printer) + db.session.commit() + + return {'pc': pc, 'printer_asset': pr_asset, 'printer': printer, 'dp_type': dp_type} + + +def _link_default(db, pc, printer_asset, dp_type): + db.session.add(AssetRelationship( + sourceassetid=pc.assetid, + targetassetid=printer_asset.assetid, + relationshiptypeid=dp_type.relationshiptypeid, + )) + db.session.commit() + + +def test_returns_default_printer_for_pc(client, db, scene): + _link_default(db, scene['pc'], scene['printer_asset'], scene['dp_type']) + + resp = client.get('/api/printers/pc-default?machine=3210') + assert resp.status_code == 200 + data = resp.get_json()['data'] + assert data['printerid'] == scene['printer'].printerid + assert data['windowsname'] == 'HP-CSF04-Materials' + + +def test_pc_without_default_returns_empty(client, db, scene): + resp = client.get('/api/printers/pc-default?machine=3210') + assert resp.status_code == 200 + assert resp.get_json()['data'] == {} + + +def test_unknown_machine_returns_empty(client, db, scene): + resp = client.get('/api/printers/pc-default?machine=NOPE') + assert resp.status_code == 200 + assert resp.get_json()['data'] == {} + + +def test_missing_machine_param_returns_empty(client, db, scene): + resp = client.get('/api/printers/pc-default') + assert resp.status_code == 200 + assert resp.get_json()['data'] == {} diff --git a/tests/test_plugins/test_printer_install_list.py b/tests/test_plugins/test_printer_install_list.py new file mode 100644 index 00000000..1a61a6e0 --- /dev/null +++ b/tests/test_plugins/test_printer_install_list.py @@ -0,0 +1,48 @@ +"""Tests for the printer installer endpoint (/api/printers/install-list). + +The signed printer installer EXE pulls network-printer data + floor-map +positions from here (shopfloor 2.0 cannot run unsigned .bat maps). +""" + +import pytest + + +@pytest.fixture +def printer_assettype(db): + from shopdb.core.models import AssetType + at = AssetType(assettype='printer', pluginname='printers', + tablename='printers', description='Printers') + db.session.add(at) + db.session.commit() + return at + + +def test_install_list_returns_network_printer_with_map(client, db, auth_headers, + printer_assettype): + """A network printer appears with flat installer fields incl map position.""" + created = client.post('/api/printers', json={ + 'assetnumber': 'CSF01-Materials-HP', + 'hostname': 'wjprn01', + 'installpath': r'\\srv\drivers\hp', + 'mapx': 120, 'mapy': 240, + }, headers=auth_headers) + assert created.status_code == 201, created.get_json() + + resp = client.get('/api/printers/install-list', headers=auth_headers) + assert resp.status_code == 200 + rows = resp.get_json()['data'] + row = next((r for r in rows if r['machinenumber'] == 'CSF01-Materials-HP'), None) + assert row is not None + assert row['hostname'] == 'wjprn01' + assert row['installpath'] == r'\\srv\drivers\hp' + assert row['mapx'] == 120 and row['mapy'] == 240 + + +def test_install_list_excludes_usb_only_printer(client, db, auth_headers, + printer_assettype): + """A printer with no hostname and no network IP is excluded.""" + client.post('/api/printers', json={'assetnumber': 'USB-LABELER'}, + headers=auth_headers) + resp = client.get('/api/printers/install-list', headers=auth_headers) + machinenumbers = [r['machinenumber'] for r in resp.get_json()['data']] + assert 'USB-LABELER' not in machinenumbers diff --git a/tests/test_plugins/test_shopfloor_feed.py b/tests/test_plugins/test_shopfloor_feed.py new file mode 100644 index 00000000..21d8b4aa --- /dev/null +++ b/tests/test_plugins/test_shopfloor_feed.py @@ -0,0 +1,72 @@ +"""Tests for the shopfloor TV feed (/api/notifications/shopfloor). + +Since the notification-type display rework, per-employee fan-out is driven by +the type's splitperemployee column (seeded true for Recognition/Training): +a note naming several comma-joined SSOs yields one card per employee; types +without the flag stay a single card. +""" + +import pytest + +from plugins.notifications.models import Notification, NotificationType + + +def _make_type(db, typename, typecolor, splitperemployee=False): + t = NotificationType(typename=typename, typecolor=typecolor, + splitperemployee=splitperemployee, isactive=True) + db.session.add(t) + db.session.commit() + return t + + +def _make_shopfloor_note(db, ntype, employeesso, employeename): + n = Notification( + notificationtypeid=ntype.notificationtypeid, + notification='msg', + businessunitid=None, + isactive=True, + isshopfloor=True, + employeesso=employeesso, + employeename=employeename, + ) + db.session.add(n) + db.session.commit() + return n + + +@pytest.mark.parametrize('typecolor', ['recognition', 'training']) +def test_multi_employee_split_into_one_card_each(client, db, typecolor): + """A split-flagged note with two SSOs yields two current cards.""" + ntype = _make_type(db, typecolor.capitalize(), typecolor, splitperemployee=True) + _make_shopfloor_note(db, ntype, '111,222', 'Alice, Bob') + + resp = client.get('/api/notifications/shopfloor') + assert resp.status_code == 200 + current = resp.get_json()['data']['current'] + assert len(current) == 2 + assert {c['employeesso'] for c in current} == {'111', '222'} + assert {c['employeename'] for c in current} == {'Alice', 'Bob'} + + +def test_non_split_type_stays_single_card(client, db): + """A type without splitperemployee is not fanned out, even with many SSOs.""" + ntype = _make_type(db, 'Awareness', 'info') + _make_shopfloor_note(db, ntype, '111,222', 'Alice, Bob') + + resp = client.get('/api/notifications/shopfloor') + assert resp.status_code == 200 + current = resp.get_json()['data']['current'] + assert len(current) == 1 + assert current[0]['employeesso'] == '111,222' + + +def test_single_employee_recognition_stays_single_card(client, db): + """One SSO produces one card (no spurious split on a lone employee).""" + ntype = _make_type(db, 'Recognition', 'recognition', splitperemployee=True) + _make_shopfloor_note(db, ntype, '111', 'Alice') + + resp = client.get('/api/notifications/shopfloor') + assert resp.status_code == 200 + current = resp.get_json()['data']['current'] + assert len(current) == 1 + assert current[0]['employeesso'] == '111' diff --git a/tests/test_security_config.py b/tests/test_security_config.py new file mode 100644 index 00000000..0cd1e179 --- /dev/null +++ b/tests/test_security_config.py @@ -0,0 +1,90 @@ +"""Tests pinning production-config validation behavior.""" + +import os +import pytest + +from shopdb.config import ProductionConfig, ConfigError + + +@pytest.fixture +def clean_env(monkeypatch): + """Clear all env vars that ProductionConfig.validate looks at.""" + for key in ('SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL', 'CORS_ORIGINS'): + monkeypatch.delenv(key, raising=False) + return monkeypatch + + +def test_production_validate_raises_on_missing_secret_key(clean_env): + """Empty SECRET_KEY in production must fail loud at boot.""" + with pytest.raises(ConfigError, match='SECRET_KEY'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_dev_secret_key(clean_env): + """The dev fallback must not be accepted in production.""" + clean_env.setenv('SECRET_KEY', 'dev-secret-key-change-in-production') + with pytest.raises(ConfigError, match='SECRET_KEY'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_missing_jwt_secret(clean_env): + """Empty JWT_SECRET_KEY in production must fail loud at boot.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + with pytest.raises(ConfigError, match='JWT_SECRET_KEY'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_missing_database_url(clean_env): + """Production must not silently fall back to a localhost MySQL URL.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key') + with pytest.raises(ConfigError, match='DATABASE_URL'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_wildcard_cors(clean_env): + """CORS wildcard is rejected in production.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key') + clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb') + clean_env.setenv('CORS_ORIGINS', '*') + with pytest.raises(ConfigError, match='CORS_ORIGINS'): + ProductionConfig.validate() + + +def test_production_validate_raises_on_empty_cors(clean_env): + """Empty CORS allowlist is rejected in production.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key') + clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb') + with pytest.raises(ConfigError, match='CORS_ORIGINS'): + ProductionConfig.validate() + + +def test_production_validate_passes_with_complete_config(clean_env): + """All required env vars set with non-default values: validate passes.""" + clean_env.setenv('SECRET_KEY', 'a-real-strong-key') + clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key') + clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb') + clean_env.setenv('CORS_ORIGINS', 'https://shopdb.example.com') + ProductionConfig.validate() + + +def test_per_plugin_collector_key_loaded_from_env(monkeypatch): + """COLLECTOR_API_KEY_ is a dynamic env var; create_app must load it. + + from_object only copies class attributes, so per-plugin keys (ADR-006) + would be invisible without the explicit env scan in create_app. + """ + from shopdb import create_app + + monkeypatch.setenv('COLLECTOR_API_KEY_COMPUTERS', 'computers-secret') + app = create_app('testing') + assert app.config.get('COLLECTOR_API_KEY_COMPUTERS') == 'computers-secret' + + +def test_employee_db_password_has_no_default(): + """No safe default for the employee-DB password: unset means empty.""" + if 'EMPLOYEE_DB_PASSWORD' not in os.environ: + from shopdb.config import Config + assert Config.EMPLOYEE_DB_PASSWORD == '' diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 00000000..886f25ab --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,113 @@ +"""Smoke tests pinning the baseline behavior of shopdb-flask. + +These eight tests are the safety net required before any structural +refactor proceeds. +""" + +import pytest + + +def test_app_factory_creates_app(app): + """create_app('testing') returns a Flask app with TESTING=True.""" + assert app is not None + assert app.config['TESTING'] is True + assert 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI'] + + +def test_login_with_valid_credentials_returns_tokens(client, admin_user): + """POST /api/auth/login with valid creds returns access and refresh tokens.""" + response = client.post( + '/api/auth/login', + json={'username': 'testadmin', 'password': 'testpass'}, + ) + assert response.status_code == 200 + payload = response.get_json() + assert 'data' in payload + data = payload['data'] + assert 'access_token' in data + assert 'refresh_token' in data + assert 'user' in data + assert data['user']['username'] == 'testadmin' + + +def test_login_with_invalid_credentials_returns_401(client, admin_user): + """Wrong password returns 401 with the documented error envelope. + + Pins the current shape: error info nested under `data.error` (not at + top level). The error_response docstring claims top-level `error` but + the implementation puts it under `data`. Pinned as-is until that + inconsistency is intentionally addressed. + """ + response = client.post( + '/api/auth/login', + json={'username': 'testadmin', 'password': 'wrongpassword'}, + ) + assert response.status_code == 401 + payload = response.get_json() + assert payload['status'] == 'error' + assert payload['data']['error']['code'] == 'UNAUTHORIZED' + + +def test_login_with_missing_fields_returns_400(client): + """Missing username or password returns 400 validation error.""" + response = client.post('/api/auth/login', json={}) + assert response.status_code == 400 + + +def test_protected_route_requires_authentication(client, admin_user): + """GET /api/users without a JWT returns 401.""" + response = client.get('/api/users') + assert response.status_code == 401 + + +def test_protected_route_works_with_jwt(client, auth_headers): + """GET /api/users with a valid JWT returns 200.""" + response = client.get('/api/users', headers=auth_headers) + assert response.status_code == 200 + + +def test_paginated_response_shape(client, auth_headers): + """A paginated list endpoint returns data plus pagination meta. + + Uses /api/locations because it is a simple platform endpoint that + uses paginated_response. Pagination meta keys follow the naming + convention (lowercase concatenated): page, perpage, total, + totalpages, hasnext, hasprev. + """ + response = client.get('/api/locations', headers=auth_headers) + assert response.status_code == 200 + payload = response.get_json() + assert 'data' in payload + assert isinstance(payload['data'], list) + assert 'meta' in payload + assert 'pagination' in payload['meta'] + pagination = payload['meta']['pagination'] + assert 'page' in pagination + assert 'perpage' in pagination + assert 'total' in pagination + assert 'totalpages' in pagination + + +def test_plugin_loader_discovers_bundled_plugins(app): + """Plugin manager finds the bundled plugins.""" + from shopdb.plugins import plugin_manager + + expected_plugins = { + 'computers', + 'employees', + 'knowledgebase', + 'machines', + 'network', + 'notifications', + 'printers', + 'slides', + 'usb', + } + + with app.app_context(): + loader = plugin_manager.loader + discovered = set(loader.discover_plugins()) + + assert expected_plugins.issubset(discovered), ( + f'Missing bundled plugins: {expected_plugins - discovered}' + ) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 00000000..829a9090 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,45 @@ +"""WSGI entry point for the application.""" + +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +from shopdb import create_app + +app = create_app(os.environ.get('FLASK_ENV', 'development')) + + +class MountPathMiddleware: + """Serve the whole app (API + SPA) under a URL prefix, e.g. '/ops'. + + Used when the app is deployed as an IIS Application under an existing + site instead of its own site: IIS forwards the full request path + ('/ops/api/...'), so the prefix is moved from PATH_INFO to SCRIPT_NAME + before Flask routes it. Flask then also generates URLs under the prefix. + The frontend must be built with the matching VITE_BASE_PATH ('/ops/'). + """ + + def __init__(self, wsgi_app, mountpath): + self.wsgi_app = wsgi_app + self.mountpath = '/' + mountpath.strip('/') + + def __call__(self, environ, start_response): + path = environ.get('PATH_INFO', '') + if path == self.mountpath or path.startswith(self.mountpath + '/'): + environ['SCRIPT_NAME'] = environ.get('SCRIPT_NAME', '') + self.mountpath + environ['PATH_INFO'] = path[len(self.mountpath):] or '/' + return self.wsgi_app(environ, start_response) + start_response('404 Not Found', [('Content-Type', 'text/plain')]) + return [b'Not Found: the app is mounted at ' + self.mountpath.encode() + b'/'] + + +# MOUNT_PATH (.env or web.config) activates the subpath deployment method. +# Unset/empty = the app owns the server root (its own IIS site; the default). +_mountpath = os.environ.get('MOUNT_PATH', '').strip() +if _mountpath and _mountpath != '/': + app.wsgi_app = MountPathMiddleware(app.wsgi_app, _mountpath) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5001, debug=True)