From b8c22244a164ec23b7f3252c98b8b576a1ee90e8 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 10 Jul 2026 15:02:07 -0400 Subject: [PATCH] Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0 Make the app distributable to other GE Aerospace sites (one self-hosted instance per site, ADR-004). GE values remain the shipped defaults; every site-specific behavior is now a Setting an admin can change in the UI. Settings-driven site config: - Branding: site/QR/badge logos, favicon, primary color (upload endpoints mirror the map-blueprint pattern; new Settings > Branding section). - ServiceNow: search/incident/change URL templates ({ticket}), ticket prefixes, enable toggle. Defaults point at the current geaerospaceqa.service-now.com global search. Disabled = plain-text tickets. - Employee-id regex (employeeid_pattern), printer hostname template, QR label targets (qr_target_printer / qr_target_usb, blank = asset page, else URL template with placeholders), usb_label_style (barcode|qr). - West Jefferson floor-plan PNGs removed from the tree; generic placeholder ships as the map default and sites upload their own blueprint. Security closeout: - dashboarddefaults writes now require admin. - Collector: generic error messages (no str(exc) leak); API key accepted via X-API-Key header only (BREAKING: querystring api_key removed). - IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout. - Setting.set() creation race fixed (IntegrityError retry). Release engineering and docs: - __version__ 0.5.0 (distinct from __contract_version__, ADR-007), CHANGELOG.md, Gitea Actions CI config, frontend version aligned. - One wizard-first install story across README/DEPLOY; new CONFIG.md, UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled. - Dockerfile multi-stage build now bundles the frontend; compose binds MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed. Debt and fixes: - .query.get() -> db.session.get() sweep; datetime.utcnow() removed (naive-UTC via timezone-aware now); users.py on authz decorators. - Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee, plugin contract purity) and the USB label page field mapping (both usb modes emit the cmmc shape: device_id/device_desc). - Health endpoint reports the real version. 248 tests pass; naming/style check green; frontend builds; fresh-DB flask db upgrade + seeds verified; QR targets verified by decoding rendered codes. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/ci.yml | 57 + CHANGELOG.md | 73 + CLAUDE.md | 21 +- Dockerfile | 26 +- README.md | 101 +- database/schema.sql | 608 -------- docker-compose.yml | 6 +- docs/BACKUP-RESTORE.md | 123 ++ docs/COLLECTOR-INTEGRATION.md | 6 + docs/CONFIG.md | 242 +++ docs/DEPLOY.md | 47 +- docs/INSTALL-WINDOWS-IIS.md | 194 +++ docs/ROADMAP.md | 7 +- docs/UPGRADE.md | 112 ++ ...ADR-007-product-versioning-and-releases.md | 115 ++ docs/adr/README.md | 1 + frontend/index.html | 1 + frontend/package.json | 2 +- frontend/public/favicon.svg | 4 + frontend/public/ge-monogram.svg | 1 + .../static/images/floorplan-placeholder.svg | 11 + frontend/src/api/index.js | 7 + frontend/src/composables/mapConfig.js | 10 +- frontend/src/main.js | 4 + frontend/src/utils/qrTarget.js | 26 + frontend/src/utils/siteSettings.js | 76 +- frontend/src/views/AppLayout.vue | 35 +- frontend/src/views/Login.vue | 6 +- frontend/src/views/SetupWizard.vue | 24 + frontend/src/views/ShopfloorDashboard.vue | 56 +- frontend/src/views/print/EquipmentBadge.vue | 4 +- frontend/src/views/print/PrinterQRBatch.vue | 13 +- frontend/src/views/print/PrinterQRSingle.vue | 14 +- frontend/src/views/print/USBLabelBatch.vue | 799 +++++----- frontend/src/views/print/qrLogo.js | 27 +- frontend/src/views/printers/PrinterForm.vue | 13 +- frontend/src/views/settings/SiteSettings.vue | 17 +- .../src/views/settings/SystemSettings.vue | 253 +++- frontend/src/views/settings/settingsNav.js | 5 +- plugins/computers/api/routes.py | 49 +- plugins/computers/plugin.py | 4 +- plugins/employees/api/routes.py | 12 +- plugins/equipment/api/routes.py | 12 +- plugins/knowledgebase/api/routes.py | 12 +- plugins/network/api/routes.py | 26 +- plugins/notifications/api/routes.py | 22 +- plugins/notifications/models/notification.py | 4 +- plugins/notifications/plugin.py | 4 +- plugins/printers/api/asset_routes.py | 28 +- plugins/slides/api/routes.py | 2 +- plugins/usb/api/routes.py | 2 +- plugins/usb/api/selfhosted.py | 8 +- plugins/usb/models/usb_device.py | 11 +- plugins/warranty/api/routes.py | 24 +- plugins/warranty/services/providers.py | 2 +- scripts/migration/one-offs/README.md | 16 + .../one-offs}/add_recertification_type.sql | 0 shopdb/__init__.py | 6 + shopdb/config.py | 11 + shopdb/core/api/applications.py | 18 +- shopdb/core/api/assets.py | 34 +- shopdb/core/api/auditlogs.py | 4 +- shopdb/core/api/auth.py | 51 +- shopdb/core/api/businessunits.py | 6 +- shopdb/core/api/collector.py | 33 +- shopdb/core/api/customfields.py | 10 +- shopdb/core/api/dashboard.py | 4 +- shopdb/core/api/dashboarddefaults.py | 12 +- shopdb/core/api/locations.py | 10 +- shopdb/core/api/machinetypes.py | 6 +- shopdb/core/api/models.py | 6 +- shopdb/core/api/operatingsystems.py | 6 +- shopdb/core/api/reports.py | 22 +- shopdb/core/api/search.py | 78 +- shopdb/core/api/settings.py | 1301 ++++++++++------- shopdb/core/api/users.py | 39 +- shopdb/core/api/vendors.py | 6 +- shopdb/core/models/auditlog.py | 8 +- shopdb/core/models/base.py | 15 +- shopdb/core/models/setting.py | 59 +- shopdb/core/models/user.py | 4 +- shopdb/plugins/registry.py | 4 +- .../static/images/floorplan-placeholder.svg | 11 + shopdb/utils/responses.py | 4 +- sql/widen_notification_employee_columns.sql | 10 - tests/test_core/test_auth_ratelimit.py | 70 + tests/test_core/test_authz.py | 43 + tests/test_core/test_collector_contract.py | 43 + tests/test_core/test_dashboarddefaults.py | 55 + tests/test_core/test_search_integrations.py | 105 ++ tests/test_core/test_setting_model.py | 56 + tests/test_core/test_settings_branding.py | 166 +++ tests/test_core/test_slides.py | 27 +- tests/test_plugins/test_shopfloor_feed.py | 20 +- tools/shot.py | 6 +- tools/shot_map_tab.py | 6 +- 96 files changed, 3818 insertions(+), 1942 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 CHANGELOG.md delete mode 100644 database/schema.sql create mode 100644 docs/BACKUP-RESTORE.md create mode 100644 docs/CONFIG.md create mode 100644 docs/INSTALL-WINDOWS-IIS.md create mode 100644 docs/UPGRADE.md create mode 100644 docs/adr/ADR-007-product-versioning-and-releases.md create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/ge-monogram.svg create mode 100644 frontend/public/static/images/floorplan-placeholder.svg create mode 100644 frontend/src/utils/qrTarget.js create mode 100644 scripts/migration/one-offs/README.md rename {sql => scripts/migration/one-offs}/add_recertification_type.sql (100%) create mode 100644 shopdb/static/images/floorplan-placeholder.svg delete mode 100644 sql/widen_notification_employee_columns.sql create mode 100644 tests/test_core/test_auth_ratelimit.py create mode 100644 tests/test_core/test_search_integrations.py create mode 100644 tests/test_core/test_setting_model.py create mode 100644 tests/test_core/test_settings_branding.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..b77476d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,57 @@ +# CI for shopdb-flask. +# +# NOTE: this is a best-effort configuration. Gitea Actions availability on +# the host is UNVERIFIED - no runner has been confirmed. Until a runner is +# registered and this workflow is observed passing, treat it as config-only. +# +# Three jobs run on push and pull_request: +# backend - pytest (tests use in-memory SQLite via TestingConfig, so no +# database service is needed). +# naming - the CONTRIBUTING.md naming/style gate. +# frontend - Vue build. + +name: CI + +on: + push: + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run backend tests + run: python -m pytest + + naming: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Run naming and style check + run: bash scripts/check-naming-and-style.sh + + frontend: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install and build frontend + run: | + npm ci + npm run build + working-directory: frontend diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..263a8a6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# 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] + +## [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, CLAUDE, and ROADMAP. +- ADR-007 (product versioning and releases), CHANGELOG, and best-effort + Gitea Actions 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`. + +[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.5.0...HEAD +[0.5.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/releases/tag/v0.5.0 diff --git a/CLAUDE.md b/CLAUDE.md index d3bc49f..11f3e9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,9 +21,9 @@ Architecture decisions live in `docs/adr/`. Read those before making schema or c `CONTRIBUTING.md` defines naming rules (DB tables, columns, Python, JS, Vue, API). Pre-commit hook at `scripts/check-naming-and-style.sh` enforces them. Read `CONTRIBUTING.md` before naming any new identifier. -## Current state (as of 2026-05-08) +## Current state (as of 2026-07-10) -Refactor phases 0-5 landed. Five commits on main, all pushed to gitea origin. +Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progress. ### Phases done @@ -36,9 +36,10 @@ Refactor phases 0-5 landed. Five commits on main, all pushed to gitea origin. ### Active state -- 101+ tests passing, naming/style check green -- `__contract_version__` at 0.2.0 -- 6 bundled plugins all satisfy contract: computers, equipment, network, notifications, printers, usb +- 206 tests passing, naming/style check green +- `__contract_version__` at 0.5.0 +- 10 bundled plugins all satisfy contract: computers, employees, equipment, knowledgebase, network, notifications, printers, slides, usb, warranty +- Single core Alembic chain: baseline `68b3947ae14f` -> head `7d16_directoryemployees` (23 migrations). A fresh site runs `flask db upgrade` from empty; it is reproducible and idempotent. - Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0 ### Deferred @@ -64,10 +65,12 @@ pip install -r requirements.txt cp .env.example .env # Edit .env with DB credentials, JWT secrets -# Create / update database tables -flask db-utils create-all +# Create / update database tables via the Alembic chain +flask db upgrade -# Seed reference data +# Seed RBAC, default settings, and reference data (all idempotent) +flask seed permissions +flask seed settings flask seed reference-data # Restart services @@ -123,4 +126,4 @@ Each plugin must have: - `migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - LocationOnly equipment type fix - `migrations/PRODUCTION_MIGRATION_GUIDE.md` - production import methods - `migrations/rename_underscore_columns.sql` - one-time rename of snake_case columns to lowercase concatenated (per CONTRIBUTING.md) -- `migrations/versions/` - Alembic versions (currently empty) +- `migrations/versions/` - the core Alembic chain (baseline `68b3947ae14f` -> head `7d16_directoryemployees`). Run `flask db upgrade` to apply. diff --git a/Dockerfile b/Dockerfile index d681f76..17291fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,14 +2,33 @@ # # 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 six core plugins; install them at runtime with -# `flask plugin install `. +# bundles all ten core plugins (computers, employees, equipment, +# knowledgebase, 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 \ @@ -37,6 +56,9 @@ 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 diff --git a/README.md b/README.md index bd6f8e5..bc0e1eb 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ shopdb-flask/ │ │ ├── router/ # Route definitions │ │ └── stores/ # Pinia stores │ └── public/ # Static assets -├── plugins/ # External plugins -├── database/ # Database schema exports +├── plugins/ # Bundled and external plugins +├── migrations/ # Alembic migration chain (flask db upgrade) ├── scripts/ # Import and utility scripts └── tests/ # Test suite ``` @@ -94,57 +94,80 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow - Python 3.8+ - Node.js 18+ -- MySQL 5.6+ +- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md) -### Backend Setup +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 internally through the GE Aerospace Gitea. 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 -# Create virtual environment +cp .env.example .env +# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS, +# and the MYSQL_* passwords (see docs/CONFIG.md 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 - -# Install dependencies pip install -r requirements.txt - -# Configure environment cp .env.example .env -# Edit .env with your database credentials +# Edit .env with your database credentials and secrets. -# Run development server +flask db upgrade +flask seed permissions +flask seed settings +flask seed reference-data flask run -``` -### Frontend Setup - -```bash +# Frontend (separate terminal) cd frontend - -# Install dependencies npm install - -# Run development server -npm run dev - -# Build for production -npm run build +npm run dev # dev server on :5173 +npm run build # production build into frontend/dist (served by Flask) ``` -### Database +Complete first-run setup at `/setup`, or run `flask seed admin` for a headless +admin account. -ShopDB Flask uses MySQL 5.6+ 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. - -The database schema is exported in `database/schema.sql`. To initialize: - -```bash -mysql -u root -p shopdb_flask < database/schema.sql -``` - -To import data from the legacy ShopDB MySQL database (one-time, see `migrations/DATA_MIGRATION_GUIDE.md`): +To import data from the legacy ShopDB MySQL database (one-time, see +`migrations/DATA_MIGRATION_GUIDE.md`): ```bash python scripts/import_from_mysql.py ``` +For the full per-site deployment runbook see [docs/DEPLOY.md](docs/DEPLOY.md); +for every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md). + ## Configuration Environment variables (`.env`): @@ -181,8 +204,18 @@ Query parameters for list endpoints: ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines. -Current plugins: +The image bundles ten plugins; only the ones a site installs are loaded: + +- **computers** - Shopfloor PCs and workstations +- **employees** - Employee directory +- **equipment** - CNC, CMM, and inspection equipment +- **knowledgebase** - Documentation and troubleshooting guides +- **network** - Network devices +- **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** - Dell warranty lookups ## Legacy Migration diff --git a/database/schema.sql b/database/schema.sql deleted file mode 100644 index 23ff098..0000000 --- a/database/schema.sql +++ /dev/null @@ -1,608 +0,0 @@ --- MySQL dump 10.13 Distrib 5.6.51, for Linux (x86_64) --- --- Host: localhost Database: shopdb_flask --- ------------------------------------------------------ --- Server version 5.6.51 - -/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!40101 SET NAMES utf8 */; -/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; -/*!40103 SET TIME_ZONE='+00:00' */; -/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; -/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; -/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; -/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; - --- --- Table structure for table `applications` --- - -DROP TABLE IF EXISTS `applications`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `applications` ( - `appid` int(11) NOT NULL AUTO_INCREMENT, - `appname` varchar(100) NOT NULL, - `appdescription` varchar(255) DEFAULT NULL, - `supportteamid` int(11) DEFAULT NULL, - `isinstallable` tinyint(1) DEFAULT NULL, - `applicationnotes` text, - `installpath` varchar(255) DEFAULT NULL, - `applicationlink` varchar(512) DEFAULT NULL, - `documentationpath` varchar(512) DEFAULT NULL, - `ishidden` tinyint(1) DEFAULT NULL, - `isprinter` tinyint(1) DEFAULT NULL, - `islicenced` tinyint(1) DEFAULT NULL, - `image` varchar(255) DEFAULT NULL, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`appid`), - UNIQUE KEY `appname` (`appname`), - KEY `supportteamid` (`supportteamid`), - CONSTRAINT `applications_ibfk_1` FOREIGN KEY (`supportteamid`) REFERENCES `supportteams` (`supportteamid`) -) ENGINE=InnoDB AUTO_INCREMENT=82 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `appowners` --- - -DROP TABLE IF EXISTS `appowners`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `appowners` ( - `appownerid` int(11) NOT NULL AUTO_INCREMENT, - `appowner` varchar(100) NOT NULL, - `sso` varchar(50) DEFAULT NULL, - `email` varchar(100) DEFAULT NULL, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`appownerid`) -) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `appversions` --- - -DROP TABLE IF EXISTS `appversions`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `appversions` ( - `appversionid` int(11) NOT NULL AUTO_INCREMENT, - `appid` int(11) NOT NULL, - `version` varchar(50) NOT NULL, - `releasedate` date DEFAULT NULL, - `notes` varchar(255) DEFAULT NULL, - `dateadded` datetime DEFAULT NULL, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`appversionid`), - UNIQUE KEY `uq_app_version` (`appid`,`version`), - CONSTRAINT `appversions_ibfk_1` FOREIGN KEY (`appid`) REFERENCES `applications` (`appid`) -) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `businessunits` --- - -DROP TABLE IF EXISTS `businessunits`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `businessunits` ( - `businessunitid` int(11) NOT NULL AUTO_INCREMENT, - `businessunit` varchar(100) NOT NULL, - `code` varchar(20) DEFAULT NULL COMMENT 'Short code', - `description` text, - `parentid` int(11) DEFAULT NULL, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`businessunitid`), - UNIQUE KEY `businessunit` (`businessunit`), - UNIQUE KEY `code` (`code`), - KEY `parentid` (`parentid`), - CONSTRAINT `businessunits_ibfk_1` FOREIGN KEY (`parentid`) REFERENCES `businessunits` (`businessunitid`) -) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `communications` --- - -DROP TABLE IF EXISTS `communications`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `communications` ( - `communicationid` int(11) NOT NULL AUTO_INCREMENT, - `machineid` int(11) NOT NULL, - `comtypeid` int(11) NOT NULL, - `ipaddress` varchar(50) DEFAULT NULL, - `subnetmask` varchar(50) DEFAULT NULL, - `gateway` varchar(50) DEFAULT NULL, - `dns1` varchar(50) DEFAULT NULL, - `dns2` varchar(50) DEFAULT NULL, - `macaddress` varchar(50) DEFAULT NULL, - `isdhcp` tinyint(1) DEFAULT NULL, - `comport` varchar(20) DEFAULT NULL, - `baudrate` int(11) DEFAULT NULL, - `databits` int(11) DEFAULT NULL, - `stopbits` varchar(10) DEFAULT NULL, - `parity` varchar(20) DEFAULT NULL, - `flowcontrol` varchar(20) DEFAULT NULL, - `port` int(11) DEFAULT NULL, - `username` varchar(100) DEFAULT NULL, - `pathname` varchar(255) DEFAULT NULL, - `pathname2` varchar(255) DEFAULT NULL COMMENT 'Secondary path for dualpath', - `isprimary` tinyint(1) DEFAULT NULL COMMENT 'Primary communication method', - `ismachinenetwork` tinyint(1) DEFAULT NULL COMMENT 'On machine network vs office network', - `notes` text, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`communicationid`), - KEY `comtypeid` (`comtypeid`), - KEY `idx_comm_ip` (`ipaddress`), - KEY `idx_comm_machine` (`machineid`), - CONSTRAINT `communications_ibfk_1` FOREIGN KEY (`machineid`) REFERENCES `machines` (`machineid`), - CONSTRAINT `communications_ibfk_2` FOREIGN KEY (`comtypeid`) REFERENCES `communicationtypes` (`comtypeid`) -) ENGINE=InnoDB AUTO_INCREMENT=117 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `communicationtypes` --- - -DROP TABLE IF EXISTS `communicationtypes`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `communicationtypes` ( - `comtypeid` int(11) NOT NULL AUTO_INCREMENT, - `comtype` varchar(50) NOT NULL, - `description` text, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`comtypeid`), - UNIQUE KEY `comtype` (`comtype`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `installedapps` --- - -DROP TABLE IF EXISTS `installedapps`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `installedapps` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `machineid` int(11) NOT NULL, - `appid` int(11) NOT NULL, - `appversionid` int(11) DEFAULT NULL, - `isactive` tinyint(1) NOT NULL, - `installeddate` datetime DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `uq_machine_app` (`machineid`,`appid`), - KEY `appid` (`appid`), - KEY `appversionid` (`appversionid`), - CONSTRAINT `installedapps_ibfk_1` FOREIGN KEY (`machineid`) REFERENCES `machines` (`machineid`), - CONSTRAINT `installedapps_ibfk_2` FOREIGN KEY (`appid`) REFERENCES `applications` (`appid`), - CONSTRAINT `installedapps_ibfk_3` FOREIGN KEY (`appversionid`) REFERENCES `appversions` (`appversionid`) -) ENGINE=InnoDB AUTO_INCREMENT=2392 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `knowledgebase` --- - -DROP TABLE IF EXISTS `knowledgebase`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `knowledgebase` ( - `linkid` int(11) NOT NULL AUTO_INCREMENT, - `appid` int(11) DEFAULT NULL, - `shortdescription` varchar(500) NOT NULL, - `linkurl` varchar(2000) DEFAULT NULL, - `keywords` varchar(500) DEFAULT NULL, - `clicks` int(11) DEFAULT NULL, - `lastupdated` datetime DEFAULT NULL, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`linkid`), - KEY `appid` (`appid`), - CONSTRAINT `knowledgebase_ibfk_1` FOREIGN KEY (`appid`) REFERENCES `applications` (`appid`) -) ENGINE=InnoDB AUTO_INCREMENT=254 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `locations` --- - -DROP TABLE IF EXISTS `locations`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `locations` ( - `locationid` int(11) NOT NULL AUTO_INCREMENT, - `locationname` varchar(100) NOT NULL, - `building` varchar(100) DEFAULT NULL, - `floor` varchar(50) DEFAULT NULL, - `room` varchar(50) DEFAULT NULL, - `description` text, - `mapimage` varchar(500) DEFAULT NULL COMMENT 'Path to floor map image', - `mapwidth` int(11) DEFAULT NULL, - `mapheight` int(11) DEFAULT NULL, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`locationid`), - UNIQUE KEY `locationname` (`locationname`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `machinerelationships` --- - -DROP TABLE IF EXISTS `machinerelationships`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `machinerelationships` ( - `relationshipid` int(11) NOT NULL AUTO_INCREMENT, - `parentmachineid` int(11) NOT NULL, - `childmachineid` int(11) NOT NULL, - `relationshiptypeid` int(11) NOT NULL, - `notes` text, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`relationshipid`), - UNIQUE KEY `uq_machine_relationship` (`parentmachineid`,`childmachineid`,`relationshiptypeid`), - KEY `childmachineid` (`childmachineid`), - KEY `relationshiptypeid` (`relationshiptypeid`), - CONSTRAINT `machinerelationships_ibfk_1` FOREIGN KEY (`parentmachineid`) REFERENCES `machines` (`machineid`), - CONSTRAINT `machinerelationships_ibfk_2` FOREIGN KEY (`childmachineid`) REFERENCES `machines` (`machineid`), - CONSTRAINT `machinerelationships_ibfk_3` FOREIGN KEY (`relationshiptypeid`) REFERENCES `relationshiptypes` (`relationshiptypeid`) -) ENGINE=InnoDB AUTO_INCREMENT=208 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `machines` --- - -DROP TABLE IF EXISTS `machines`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `machines` ( - `machineid` int(11) NOT NULL AUTO_INCREMENT, - `machinenumber` varchar(50) NOT NULL COMMENT 'Business identifier (e.g., CMM01, G5QX1GT3ESF)', - `alias` varchar(100) DEFAULT NULL COMMENT 'Friendly name', - `hostname` varchar(100) DEFAULT NULL COMMENT 'Network hostname (for PCs)', - `serialnumber` varchar(100) DEFAULT NULL COMMENT 'Hardware serial number', - `machinetypeid` int(11) NOT NULL, - `pctypeid` int(11) DEFAULT NULL COMMENT 'Set for PCs, NULL for equipment', - `businessunitid` int(11) DEFAULT NULL, - `modelnumberid` int(11) DEFAULT NULL, - `vendorid` int(11) DEFAULT NULL, - `statusid` int(11) DEFAULT NULL COMMENT 'In Use, Spare, Retired, etc.', - `locationid` int(11) DEFAULT NULL, - `mapleft` int(11) DEFAULT NULL COMMENT 'X coordinate on floor map', - `maptop` int(11) DEFAULT NULL COMMENT 'Y coordinate on floor map', - `islocationonly` tinyint(1) DEFAULT NULL COMMENT 'Virtual location marker (not actual machine)', - `osid` int(11) DEFAULT NULL, - `loggedinuser` varchar(100) DEFAULT NULL, - `lastreporteddate` datetime DEFAULT NULL, - `lastboottime` datetime DEFAULT NULL, - `isvnc` tinyint(1) DEFAULT NULL COMMENT 'VNC remote access enabled', - `iswinrm` tinyint(1) DEFAULT NULL COMMENT 'WinRM enabled', - `isshopfloor` tinyint(1) DEFAULT NULL COMMENT 'Shopfloor PC', - `requiresmanualconfig` tinyint(1) DEFAULT NULL COMMENT 'Multi-PC machine needs manual configuration', - `notes` text, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - `deleteddate` datetime DEFAULT NULL, - `deletedby` varchar(100) DEFAULT NULL, - `createdby` varchar(100) DEFAULT NULL, - `modifiedby` varchar(100) DEFAULT NULL, - PRIMARY KEY (`machineid`), - UNIQUE KEY `ix_machines_machinenumber` (`machinenumber`), - KEY `pctypeid` (`pctypeid`), - KEY `businessunitid` (`businessunitid`), - KEY `modelnumberid` (`modelnumberid`), - KEY `vendorid` (`vendorid`), - KEY `statusid` (`statusid`), - KEY `osid` (`osid`), - KEY `idx_machine_active` (`isactive`), - KEY `idx_machine_hostname` (`hostname`), - KEY `idx_machine_type_bu` (`machinetypeid`,`businessunitid`), - KEY `ix_machines_serialnumber` (`serialnumber`), - KEY `ix_machines_hostname` (`hostname`), - KEY `idx_machine_location` (`locationid`), - CONSTRAINT `machines_ibfk_1` FOREIGN KEY (`machinetypeid`) REFERENCES `machinetypes` (`machinetypeid`), - CONSTRAINT `machines_ibfk_2` FOREIGN KEY (`pctypeid`) REFERENCES `pctypes` (`pctypeid`), - CONSTRAINT `machines_ibfk_3` FOREIGN KEY (`businessunitid`) REFERENCES `businessunits` (`businessunitid`), - CONSTRAINT `machines_ibfk_4` FOREIGN KEY (`modelnumberid`) REFERENCES `models` (`modelnumberid`), - CONSTRAINT `machines_ibfk_5` FOREIGN KEY (`vendorid`) REFERENCES `vendors` (`vendorid`), - CONSTRAINT `machines_ibfk_6` FOREIGN KEY (`statusid`) REFERENCES `machinestatuses` (`statusid`), - CONSTRAINT `machines_ibfk_7` FOREIGN KEY (`locationid`) REFERENCES `locations` (`locationid`), - CONSTRAINT `machines_ibfk_8` FOREIGN KEY (`osid`) REFERENCES `operatingsystems` (`osid`) -) ENGINE=InnoDB AUTO_INCREMENT=639 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `machinestatuses` --- - -DROP TABLE IF EXISTS `machinestatuses`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `machinestatuses` ( - `statusid` int(11) NOT NULL AUTO_INCREMENT, - `status` varchar(50) NOT NULL, - `description` text, - `color` varchar(20) DEFAULT NULL COMMENT 'CSS color for UI', - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`statusid`), - UNIQUE KEY `status` (`status`) -) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `machinetypes` --- - -DROP TABLE IF EXISTS `machinetypes`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `machinetypes` ( - `machinetypeid` int(11) NOT NULL AUTO_INCREMENT, - `machinetype` varchar(100) NOT NULL, - `category` varchar(50) NOT NULL COMMENT 'Equipment, PC, Network, or Printer', - `description` text, - `icon` varchar(50) DEFAULT NULL COMMENT 'Icon name for UI', - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`machinetypeid`), - UNIQUE KEY `machinetype` (`machinetype`) -) ENGINE=InnoDB AUTO_INCREMENT=29 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `models` --- - -DROP TABLE IF EXISTS `models`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `models` ( - `modelnumberid` int(11) NOT NULL AUTO_INCREMENT, - `modelnumber` varchar(100) NOT NULL, - `machinetypeid` int(11) DEFAULT NULL, - `vendorid` int(11) DEFAULT NULL, - `description` text, - `imageurl` varchar(500) DEFAULT NULL COMMENT 'URL to product image', - `documentationurl` varchar(500) DEFAULT NULL COMMENT 'URL to documentation', - `notes` text, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`modelnumberid`), - UNIQUE KEY `uq_model_vendor` (`modelnumber`,`vendorid`), - KEY `machinetypeid` (`machinetypeid`), - KEY `vendorid` (`vendorid`), - CONSTRAINT `models_ibfk_1` FOREIGN KEY (`machinetypeid`) REFERENCES `machinetypes` (`machinetypeid`), - CONSTRAINT `models_ibfk_2` FOREIGN KEY (`vendorid`) REFERENCES `vendors` (`vendorid`) -) ENGINE=InnoDB AUTO_INCREMENT=105 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `operatingsystems` --- - -DROP TABLE IF EXISTS `operatingsystems`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `operatingsystems` ( - `osid` int(11) NOT NULL AUTO_INCREMENT, - `osname` varchar(100) NOT NULL, - `osversion` varchar(50) DEFAULT NULL, - `architecture` varchar(20) DEFAULT NULL COMMENT 'x86, x64, ARM', - `endoflife` date DEFAULT NULL COMMENT 'End of support date', - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`osid`), - UNIQUE KEY `uq_os_name_version` (`osname`,`osversion`) -) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `pctypes` --- - -DROP TABLE IF EXISTS `pctypes`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `pctypes` ( - `pctypeid` int(11) NOT NULL AUTO_INCREMENT, - `pctype` varchar(100) NOT NULL, - `description` text, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`pctypeid`), - UNIQUE KEY `pctype` (`pctype`) -) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `printerdata` --- - -DROP TABLE IF EXISTS `printerdata`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `printerdata` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `machineid` int(11) NOT NULL, - `windowsname` varchar(255) DEFAULT NULL COMMENT 'Windows printer name (e.g., \\\\server\\printer)', - `sharename` varchar(100) DEFAULT NULL COMMENT 'CSF/share name', - `iscsf` tinyint(1) DEFAULT NULL COMMENT 'Is CSF printer', - `installpath` varchar(255) DEFAULT NULL COMMENT 'Driver install path', - `pin` varchar(20) DEFAULT NULL, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `ix_printerdata_machineid` (`machineid`), - KEY `idx_printer_windowsname` (`windowsname`), - CONSTRAINT `printerdata_ibfk_1` FOREIGN KEY (`machineid`) REFERENCES `machines` (`machineid`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `relationshiptypes` --- - -DROP TABLE IF EXISTS `relationshiptypes`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `relationshiptypes` ( - `relationshiptypeid` int(11) NOT NULL AUTO_INCREMENT, - `relationshiptype` varchar(50) NOT NULL, - `description` text, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`relationshiptypeid`), - UNIQUE KEY `relationshiptype` (`relationshiptype`) -) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `roles` --- - -DROP TABLE IF EXISTS `roles`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `roles` ( - `roleid` int(11) NOT NULL AUTO_INCREMENT, - `rolename` varchar(50) NOT NULL, - `description` text, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`roleid`), - UNIQUE KEY `rolename` (`rolename`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `supportteams` --- - -DROP TABLE IF EXISTS `supportteams`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `supportteams` ( - `supportteamid` int(11) NOT NULL AUTO_INCREMENT, - `teamname` varchar(100) NOT NULL, - `teamurl` varchar(255) DEFAULT NULL, - `appownerid` int(11) DEFAULT NULL, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`supportteamid`), - KEY `appownerid` (`appownerid`), - CONSTRAINT `supportteams_ibfk_1` FOREIGN KEY (`appownerid`) REFERENCES `appowners` (`appownerid`) -) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `userroles` --- - -DROP TABLE IF EXISTS `userroles`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `userroles` ( - `userid` int(11) NOT NULL, - `roleid` int(11) NOT NULL, - PRIMARY KEY (`userid`,`roleid`), - KEY `roleid` (`roleid`), - CONSTRAINT `userroles_ibfk_1` FOREIGN KEY (`userid`) REFERENCES `users` (`userid`), - CONSTRAINT `userroles_ibfk_2` FOREIGN KEY (`roleid`) REFERENCES `roles` (`roleid`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `users` --- - -DROP TABLE IF EXISTS `users`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `users` ( - `userid` int(11) NOT NULL AUTO_INCREMENT, - `username` varchar(100) NOT NULL, - `email` varchar(255) NOT NULL, - `passwordhash` varchar(255) NOT NULL, - `firstname` varchar(100) DEFAULT NULL, - `lastname` varchar(100) DEFAULT NULL, - `lastlogindate` datetime DEFAULT NULL, - `failedlogins` int(11) DEFAULT NULL, - `lockeduntil` datetime DEFAULT NULL, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`userid`), - UNIQUE KEY `email` (`email`), - UNIQUE KEY `ix_users_username` (`username`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `vendors` --- - -DROP TABLE IF EXISTS `vendors`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `vendors` ( - `vendorid` int(11) NOT NULL AUTO_INCREMENT, - `vendor` varchar(100) NOT NULL, - `description` text, - `website` varchar(255) DEFAULT NULL, - `supportphone` varchar(50) DEFAULT NULL, - `supportemail` varchar(100) DEFAULT NULL, - `notes` text, - `createddate` datetime NOT NULL, - `modifieddate` datetime NOT NULL, - `isactive` tinyint(1) NOT NULL, - PRIMARY KEY (`vendorid`), - UNIQUE KEY `vendor` (`vendor`) -) ENGINE=InnoDB AUTO_INCREMENT=44 DEFAULT CHARSET=latin1; -/*!40101 SET character_set_client = @saved_cs_client */; -/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; - -/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; -/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; -/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; -/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; -/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; -/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; - --- Dump completed on 2026-01-13 21:07:15 diff --git a/docker-compose.yml b/docker-compose.yml index 95b8543..8a7d62d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,8 +29,12 @@ services: 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: - - "${MYSQL_PORT:-3306}:3306" + - "127.0.0.1:${MYSQL_PORT:-3306}:3306" healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] interval: 10s diff --git a/docs/BACKUP-RESTORE.md b/docs/BACKUP-RESTORE.md new file mode 100644 index 0000000..75d9cd3 --- /dev/null +++ b/docs/BACKUP-RESTORE.md @@ -0,0 +1,123 @@ +# Backup and Restore + +Each site owns its own data (single-tenant, ADR-004), so backups are the site's +responsibility. A complete backup is two parts: + +1. **The MySQL database** - all asset, user, audit, and settings data. +2. **The `instance/` directory** - uploaded floor plans, branding assets, + `plugins.json` (the enabled-plugin list), and any tokens or files the app + writes to disk. These are NOT in the database, so a DB-only backup loses + them. Back up `instance/` alongside every database dump. + +Restoring the database without the matching `instance/` directory leaves the +app pointing at floor plans and logos that no longer exist. + +## What to back up + +| Item | Location | Why | +|------|----------|-----| +| Database | MySQL `shopdb_flask` | All application data. | +| `instance/branding/` | repo `instance/` dir | Uploaded logos and favicon. | +| `instance/` floor plans | repo `instance/` dir | Uploaded map blueprints. | +| `instance/plugins.json` | repo `instance/` dir | Which plugins this site enabled. | +| `.env` | repo root (offline, secured) | Secrets needed to bring the stack back up. Store separately from the data backup, in a secrets manager. | + +## Backup + +### Database (Docker) + +```bash +docker compose exec -T db mysqldump \ + -u root -p"${MYSQL_ROOT_PASSWORD}" \ + --single-transaction --routines --triggers \ + shopdb_flask | gzip > shopdb-$(date +%F).sql.gz +``` + +`--single-transaction` gives a consistent dump without locking the tables (InnoDB). + +### Database (external MySQL, no container) + +```bash +mysqldump -h -u -p \ + --single-transaction --routines --triggers \ + shopdb_flask | gzip > shopdb-$(date +%F).sql.gz +``` + +### instance directory + +```bash +tar czf instance-$(date +%F).tar.gz instance/ +``` + +Recommended cadence: nightly database dump to offsite storage, 14-day +retention; `instance/` captured on the same schedule (and always right before an +upgrade). Verify a restore quarterly. + +## Restore + +Restoring replaces the current database contents. Do it into a known-empty or a +throwaway target first if you are unsure. + +### Step 1: Bring up the stack (or a fresh one) + +```bash +cp .env.example .env # or restore your saved .env +# ensure MYSQL_* and DATABASE_URL match the dump's database name (shopdb_flask) +docker compose up -d db +``` + +Wait for the `db` container to report healthy (`docker compose ps`). + +### Step 2: Load the database dump + +```bash +gunzip -c shopdb-2026-07-10.sql.gz | \ + docker compose exec -T db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask +``` + +For an external MySQL: + +```bash +gunzip -c shopdb-2026-07-10.sql.gz | mysql -h -u -p shopdb_flask +``` + +If the target database does not exist yet, create it as utf8mb4 first (matching +the schema charset): + +```sql +CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +### Step 3: Restore the instance directory + +```bash +tar xzf instance-2026-07-10.tar.gz # restores ./instance/ +``` + +The docker-compose api container reads `instance/` from the repo working +directory; make sure it is present before starting `api`. + +### Step 4: Bring up the API and reconcile migrations + +```bash +docker compose up -d api +docker compose exec api flask db upgrade +``` + +`flask db upgrade` is a safety net: if the dump predates the current code, this +applies any newer migrations. If the dump is at the same version it is a no-op. + +### Step 5: Verify + +- Log in with a known account. +- Confirm the floor map renders (branding and map blueprints resolve from + `instance/`). +- Spot-check a few asset records and the audit log. +- `curl -s -X POST -H "Content-Type: application/json" -d '{}' http://localhost:5001/api/auth/login | jq .` + should return a `VALIDATION_ERROR`, not a 500. + +## See also + +- [DEPLOY.md](DEPLOY.md) - first-time deploy +- [UPGRADE.md](UPGRADE.md) - upgrade procedure (back up first) +- [CONFIG.md](CONFIG.md) - environment variables and Setting keys diff --git a/docs/COLLECTOR-INTEGRATION.md b/docs/COLLECTOR-INTEGRATION.md index 6edb752..a7a343b 100644 --- a/docs/COLLECTOR-INTEGRATION.md +++ b/docs/COLLECTOR-INTEGRATION.md @@ -11,6 +11,12 @@ Auth: API key header `X-API-Key: `, resolved as `COLLECTOR_API_KEY_COMPUTER then the shared `COLLECTOR_API_KEY` (ADR-006). Idempotent upsert keyed on `hostname`. +> **Breaking change:** the API key must be sent in the `X-API-Key` header. The +> old `?api_key=` querystring fallback has been removed, on every collector +> endpoint (`/api/collector/`, `/pc`, `/apps`, `/heartbeat`, `/bulk`, +> `/status`). Querystring keys leak into access logs and proxy history. Update +> any caller still passing `api_key` in the URL to use the header instead. + ## Payload (project naming convention: lowercase concatenated) | Field | Meaning | Flask target | diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 0000000..ba4e904 --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,242 @@ +# Configuration Reference + +shopdb-flask reads configuration from two places, and the split is deliberate: + +- **Environment variables** (`.env` / container env) hold **secrets and + deploy-time wiring**: database credentials, signing keys, CORS origins, ports, + API keys. These are read once at boot by `shopdb/config.py`. Never put a + secret in the Settings table. +- **The Settings table** (seeded by `flask seed settings`, edited in the UI + under Settings or the setup wizard) holds **site preferences**: branding, + ServiceNow links, floor-map images, search toggles, facility identity. These + can change at runtime without a restart and are per-instance. + +Rule of thumb: if leaking it would be a security incident, it is an environment +variable. If it is a site preference an admin should be able to change in the +UI, it is a Setting. + +--- + +## Part 1: Environment variables + +Defined in `shopdb/config.py`. Copy `.env.example` to `.env` and fill in +values. In `production` (`FLASK_ENV=production`), `ProductionConfig.validate()` +refuses to boot if `SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, or +`CORS_ORIGINS` are missing or set to the dev defaults. + +### Flask core + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `FLASK_APP` | No | `wsgi.py` | Entry point for the `flask` CLI. | +| `FLASK_ENV` | Yes | `development` | `production` for live sites (triggers `validate()`). Other values: `development`, `testing`. | +| `SECRET_KEY` | Yes (prod) | dev default | Flask session/signing key. Generate: `python -c "import secrets; print(secrets.token_urlsafe(64))"`. | +| `JWT_SECRET_KEY` | Yes (prod) | dev default | JWT signing key. Different value from `SECRET_KEY`. | +| `JWT_ACCESS_TOKEN_EXPIRES` | No | `3600` | Access-token TTL in seconds. | +| `JWT_REFRESH_TOKEN_EXPIRES` | No | `2592000` | Refresh-token TTL in seconds (30 days). | +| `CORS_ORIGINS` | Yes (prod) | `http://localhost:5173` | Comma-separated explicit origins. Wildcard `*` is rejected in production. | +| `LOG_LEVEL` | No | `INFO` | Logging verbosity. | + +### Database + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `DATABASE_URL` | Yes (prod) | dev localhost URL | `mysql+pymysql://:@:/?charset=utf8mb4`. Keep `?charset=utf8mb4`. | + +### Authentication rate limiting + +IP-based fixed-window limit on the login endpoint, defense-in-depth atop the +per-account lockout. Uses the existing cache extension (per-process, so the +limit is approximate across multiple gunicorn workers). + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `AUTH_RATELIMIT_ENABLED` | No | `True` | Set `False` to disable (TestingConfig disables it). | +| `AUTH_RATELIMIT_MAX` | No | `30` | Max login attempts per source IP per window before 429. | +| `AUTH_RATELIMIT_WINDOW_SECONDS` | No | `300` | Window length in seconds. | + +### Collector ingest (ADR-006) + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset. Sent as the `X-API-Key` header. | +| `COLLECTOR_API_KEY_` | No | (empty) | Per-plugin override, e.g. `COLLECTOR_API_KEY_COMPUTERS`. Checked before the shared key. | + +### Zabbix (printer supply monitoring) + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `ZABBIX_ENABLED` | No | `false` | Enable the Zabbix integration. | +| `ZABBIX_URL` | No | (empty) | Zabbix API URL. | +| `ZABBIX_TOKEN` | No | (empty) | Zabbix API bearer token. | + +Note: Zabbix can also be configured via the Settings table (`zabbix_enabled`, +`zabbix_url`, `zabbix_token`). The environment values are the boot-time wiring; +prefer the Settings entries for runtime changes. + +### Employee directory database (optional, read-only) + +Separate HR/employee lookup DB consumed by the notifications plugin and the +public kiosks. There is no safe default for the password; an unset password +fails loud rather than trying a guessed credential. + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `EMPLOYEE_DB_HOST` | No | `localhost` | HR DB host. | +| `EMPLOYEE_DB_USER` | No | (empty) | HR DB user. | +| `EMPLOYEE_DB_PASSWORD` | No | (empty) | HR DB password. No safe default. | +| `EMPLOYEE_DB_NAME` | No | `wjf_employees` | HR DB name. | + +Only used when `employee_directory_mode` (Setting) is `external`. + +### CMMC USB database (optional, read-write) + +Separate MySQL DB used by the USB plugin for check-in/out, lockers, and the log. + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `CMMC_USB_DB_HOST` | No | `localhost` | USB DB host. | +| `CMMC_USB_DB_USER` | No | (empty) | USB DB user. | +| `CMMC_USB_DB_PASSWORD` | No | (empty) | USB DB password. No safe default. | +| `CMMC_USB_DB_NAME` | No | `cmmc_usb` | USB DB name. | + +Only used when `usb_directory_mode` (Setting) is `external`. + +### docker-compose only + +Read by `docker-compose.yml`, not by the Flask app directly. + +| Variable | Required | Default | Notes | +|----------|----------|---------|-------| +| `MYSQL_ROOT_PASSWORD` | Yes | (none) | Root password for the bundled MySQL container. | +| `MYSQL_PASSWORD` | Yes | (none) | App-user password; must match the `DATABASE_URL` password. | +| `MYSQL_PORT` | No | `3306` | Host port for MySQL. Bound to `127.0.0.1` only. | +| `API_PORT` | No | `5001` | Host port for the API container. | + +--- + +## Part 2: Settings table keys + +Seeded by `flask seed settings` (idempotent; re-running adds anything missing). +Edited in the UI under Settings, or captured in the first-run setup wizard. +Values are stored as strings and typed by `valuetype`. Secrets in this table +(anything whose key contains `password`, `token`, or `secret`) are masked when +read back through the API. + +### site + +| Key | Default | Notes | +|-----|---------|-------| +| `setup_complete` | `false` | Set true once the first-run wizard finishes; gates the `/setup` route. | +| `employee_directory_mode` | `selfhosted` | `selfhosted` (tables in this app) or `external` (a separate HR database, see `EMPLOYEE_DB_*`). | +| `usb_directory_mode` | `selfhosted` | `selfhosted` or `external` (a separate `cmmc_usb` database, see `CMMC_USB_DB_*`). | +| `site_base_url` | (empty) | Public base URL (scheme + host) for QR codes and absolute links. Blank = use the browsing origin. | +| `facility_name` | (empty) | Facility name in the dashboard header. Blank = frontend falls back to `ShopDB`. | +| `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. | +| `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. | +| `printer_hostname_template` | `Printer-{ip}.printer.geaerospace.net` | Printer hostname template. `{ip}` is the dash-separated IP address. | + +### branding + +Blank values fall back to the shipped GE default asset so an un-reconfigured +install still renders. Upload replacements at Settings > Branding, which saves +them under `instance/branding/`. + +| Key | Default | Notes | +|-----|---------|-------| +| `site_logo` | `/ge-aerospace-logo.svg` | Header and login-page logo. | +| `qr_logo` | `/ge-monogram.svg` | Logo composited in printer QR labels. Blank = no overlay. | +| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the equipment badge print page. | +| `site_favicon` | (empty) | Browser tab favicon. Blank = shipped `/favicon.svg`. | +| `brand_primary_color` | (empty) | Primary brand color as a CSS color value. Blank = built-in theme color. | + +### printing + +| Key | Default | Notes | +|-----|---------|-------| +| `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. | +| `qr_target_usb` | (empty) | Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: `{id}`, `{serialnumber}`, `{alias}`. | +| `usb_label_style` | `barcode` | USB mini-label code style: `barcode` (CODE128 of the serial) or `qr` (QR code linking to the USB QR target). | + +### map + +| Key | Default | Notes | +|-----|---------|-------| +| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Map. | +| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (dark theme). | +| `map_width` | `3300` | Blueprint native width in pixels. | +| `map_height` | `2550` | Blueprint native height in pixels. | + +### integrations + +| Key | Default | Notes | +|-----|---------|-------| +| `servicenow_enabled` | `true` | Enable ServiceNow ticket recognition and links. Disabled = tickets render as plain text. | +| `servicenow_search_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. | +| `servicenow_ticket_prefixes` | `GEINC,GECHG,GERIT,GESCT` | Comma-separated prefixes recognized as ServiceNow tickets. | +| `servicenow_incident_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct incident URL if your instance has one. | +| `servicenow_change_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct change URL if your instance has one. | +| `zabbix_enabled` | `false` | Enable Zabbix for printer supply monitoring. | +| `zabbix_url` | (empty) | Zabbix API URL. | +| `zabbix_token` | (empty) | Zabbix API token (masked). | +| `warranty_dell_enabled` | `false` | Enable Dell warranty (service-tag) lookups. | +| `warranty_dell_clientid` | (empty) | Dell TechDirect API client id. | +| `warranty_dell_clientsecret` | (empty) | Dell TechDirect API client secret (masked). | +| `warranty_dell_tokenurl` | (empty) | Dell OAuth token URL. Blank = Dell default. | +| `warranty_dell_apiurl` | (empty) | Dell warranty API URL. Blank = Dell default. | + +### email + +| Key | Default | Notes | +|-----|---------|-------| +| `smtp_enabled` | `false` | Enable email notifications and alerts. | +| `smtp_host` | (empty) | SMTP server hostname. | +| `smtp_port` | `587` | SMTP port (587 TLS, 465 SSL, 25 plain). | +| `smtp_username` | (empty) | SMTP auth username. | +| `smtp_password` | (empty) | SMTP auth password (masked). | +| `smtp_use_tls` | `true` | Use TLS for the SMTP connection. | +| `smtp_from_address` | (empty) | From address for outgoing email. | +| `smtp_from_name` | `ShopDB` | From name for outgoing email. | +| `alert_recipients` | (empty) | Default alert recipients (comma-separated). | + +### audit + +| Key | Default | Notes | +|-----|---------|-------| +| `audit_retention_days` | `90` | Days to retain audit logs (0 = keep forever). | + +### auth + +| Key | Default | Notes | +|-----|---------|-------| +| `saml_enabled` | `false` | Enable SAML SSO. | +| `saml_idp_metadata_url` | (empty) | SAML IdP metadata URL. | +| `saml_entity_id` | (empty) | SAML SP entity id. | +| `saml_acs_url` | (empty) | SAML Assertion Consumer Service URL. | +| `saml_allow_local_login` | `true` | Allow local username/password login when SAML is on. | +| `saml_auto_create_users` | `true` | Auto-create users on first SAML login. | +| `saml_admin_group` | (empty) | SAML group name that grants the admin role. | + +### identifiers (dynamic) + +One boolean key per asset identifier per asset type, keyed +`identifier___enabled` (default `true`). Admins choose which +optional identifiers show on which asset types. See ADR-001. The exact set is +generated from `IDENTIFIER_LABELS` x `IDENTIFIER_ASSETTYPES` in +`shopdb/core/api/settings.py`. + +### search (dynamic) + +One boolean key per search domain, keyed `search__enabled` (default +`true`). Toggles whether a domain appears in global search results. The set is +generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`. + +--- + +## See also + +- [DEPLOY.md](DEPLOY.md) - per-site deployment runbook +- [UPGRADE.md](UPGRADE.md) - upgrading an existing site +- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - backup and restore +- `shopdb/config.py` - authoritative env-var definitions +- `shopdb/core/api/settings.py` (`build_default_settings`) - authoritative Setting defaults diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index d7a3c94..e2a0ab4 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -43,6 +43,13 @@ docker compose build docker compose up -d ``` +The Docker image builds the Vue frontend in a first stage and copies the +compiled SPA into the API image, so `docker compose build` produces a +self-contained image with the UI already built. No separate Node step is +needed for a container deploy. (For a bare-metal/venv install instead, build +the frontend by hand: `cd frontend && npm ci && npm run build`, which writes +`frontend/dist/` for Flask to serve.) + The MySQL container initializes its volume on first run. The API container waits for `db` to be healthy via `healthcheck`. Check logs: ```bash @@ -67,17 +74,30 @@ CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; `DATABASE_URL` must keep `?charset=utf8mb4` so the connection matches. On MySQL older than 5.7 also enable `innodb_large_prefix=ON` + `innodb_file_format=Barracuda`, or the utf8mb4 indexes exceed the 767-byte prefix limit (error 1071). MySQL 5.7+ and 8.0 need no extra config. -## Step 4: Seed reference data +## Step 4: Seed permissions, settings, and reference data + +Run all three seeders. They are idempotent, so re-running them on an existing +database is safe (it adds anything missing and leaves existing rows alone). ```bash +docker compose exec api flask seed permissions +docker compose exec api flask seed settings docker compose exec api flask seed reference-data ``` -Creates: default `Vendor`, `Location`, `BusinessUnit`, `OperatingSystem`, `AssetStatus`, `RelationshipType` rows seeded with the platform contract values (`partof`, `controls`, `connectedto`). +- `seed permissions` - creates the RBAC permission rows and the default roles + the app checks with `@require_permission`. Run this before anyone logs in or + permission checks have nothing to match. +- `seed settings` - writes the default Setting rows (branding, ServiceNow + integration, floor-map placeholders, search toggles, site identity). A site + overrides these later in Settings or the setup wizard. +- `seed reference-data` - creates default `Vendor`, `Location`, `BusinessUnit`, + `OperatingSystem`, `AssetStatus`, `RelationshipType` rows seeded with the + platform contract values (`partof`, `controls`, `connectedto`). ## Step 5: Pick plugins to enable -The image bundles all six plugins (computers, equipment, network, notifications, printers, usb). Only enabled plugins are loaded. +The image bundles ten plugins (computers, employees, equipment, knowledgebase, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded. ```bash docker compose exec api flask plugin list @@ -88,7 +108,16 @@ docker compose exec api flask plugin install equipment To install a sister-site or third-party plugin (per ADR-003), drop its directory into `/plugins//` (the docker-compose mounts this read-only into the container) and run `flask plugin install `. -## Step 6: Create the admin user +## Step 6: Create the first admin (setup wizard) + +The primary path is the first-run setup wizard. Once the stack is up and the DB +is seeded, browse to the site (through the reverse proxy configured in Step 7, +or directly at the API port during bring-up) and go to `/setup`. The wizard +creates the first admin account and captures site identity (facility name, +optional logo). It runs only while `setup_complete` is false; after it finishes +the route redirects to the app. + +Headless alternative (no browser, e.g. automated provisioning): ```bash docker compose exec api flask seed admin --username admin --email admin@facility.example.com @@ -129,7 +158,10 @@ Per-site MySQL backups are the site's responsibility. Recommended: nightly `mysq docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask | gzip > backup-$(date +%F).sql.gz ``` -Verify a restore quarterly. +Verify a restore quarterly. Back up the `instance/` directory alongside the DB; +it holds uploaded floor plans, branding, `plugins.json`, and tokens that are not +in MySQL. See [docs/BACKUP-RESTORE.md](BACKUP-RESTORE.md) for the full backup and +restore procedure. ## Step 9: Updates @@ -140,7 +172,7 @@ docker compose up -d api docker compose exec api flask db upgrade ``` -The framework's `__contract_version__` may have moved. Check `docs/adr/` for any new ADRs since the last update. If an ADR introduces a breaking change, the upgrade may require coordinated work; the ADR's "Consequences" section documents it. +The framework's `__contract_version__` may have moved. Check `docs/adr/` for any new ADRs since the last update. If an ADR introduces a breaking change, the upgrade may require coordinated work; the ADR's "Consequences" section documents it. See [docs/UPGRADE.md](UPGRADE.md) for the full upgrade procedure, including re-seeding and the v0.5+ floor-plan note. ## Common issues @@ -169,4 +201,7 @@ If this returns a 500 or no JSON, the container is unhealthy. Check `docker comp - [docs/adr/ADR-003-plugin-distribution.md](adr/ADR-003-plugin-distribution.md) - bundled vs external plugins - [docs/adr/ADR-006-collector-contract.md](adr/ADR-006-collector-contract.md) - per-plugin collector endpoints - [docs/PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - building a custom plugin for your site +- [docs/CONFIG.md](CONFIG.md) - every environment variable and every Setting key +- [docs/UPGRADE.md](UPGRADE.md) - upgrade procedure for an existing site +- [docs/BACKUP-RESTORE.md](BACKUP-RESTORE.md) - backup and restore procedure - [shopdb/config.py](../shopdb/config.py) - all the env-vars in one place diff --git a/docs/INSTALL-WINDOWS-IIS.md b/docs/INSTALL-WINDOWS-IIS.md new file mode 100644 index 0000000..afe2143 --- /dev/null +++ b/docs/INSTALL-WINDOWS-IIS.md @@ -0,0 +1,194 @@ +# ShopDB - Windows + IIS install runbook + +A step-by-step, **tested** install for a new site on Windows Server / Windows 11 +with IIS in front of the Flask app (HttpPlatformHandler -> waitress), backed by +MySQL. This runbook was validated end to end on a win11 + IIS + MySQL 5.6 box. + +`APP_ROOT` below = the deploy folder, e.g. `C:\shopdb-flask` (where `wsgi.py` +lives). Run PowerShell as Administrator. + +--- + +## 0. Prerequisites + +| Need | Notes | +| --- | --- | +| **Python 3.12** (64-bit) | `python --version` | +| **IIS** with **HttpPlatformHandler** | https://www.iis.net/downloads/microsoft/httpplatformhandler (direct MSI: `download.microsoft.com/download/8/1/3/813AC4E6-9203-4F7A-8DD5-F3D54D10C5CD/httpPlatformHandler_amd64.msi`) | +| **MySQL 5.7+/8.0** (or 5.6 with the flags in step 1) | reachable from the app host | +| URL Rewrite (optional) | only for the real-client-IP rule; skip it and the app still runs | + +The app itself pulls in `waitress` and `tzdata` from `requirements.txt` (step 4). + +--- + +## 1. MySQL: flags (5.6 only) + database + user + +On **MySQL 5.6 only**, add to `my.ini`/`my.cnf` under `[mysqld]` and restart MySQL +(5.7+/8.0 need none of this): + +``` +innodb_file_per_table = 1 +innodb_file_format = Barracuda +innodb_large_prefix = 1 +``` + +Without them, `flask db upgrade` fails with **error 1071** ("key too long") - the +migrations use `ROW_FORMAT=DYNAMIC`, which needs the 3072-byte prefix these unlock. + +Then create the database (utf8mb4) and an app user: + +```sql +CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'shopdb'@'%' IDENTIFIED BY 'CHANGE_ME'; +GRANT ALL PRIVILEGES ON shopdb_flask.* TO 'shopdb'@'%'; +FLUSH PRIVILEGES; +``` + +--- + +## 2. Deploy the app files + +Copy the release (the repo minus `venv/`, `.git/`, `node_modules/`, +`frontend/src/`) to `APP_ROOT`. It must contain `wsgi.py`, `shopdb/`, `plugins/`, +`migrations/`, `requirements.txt`, and the pre-built `frontend/dist/`. + +--- + +## 3. Virtual env + dependencies + +```powershell +cd APP_ROOT +python -m venv venv +venv\Scripts\python -m pip install -r requirements.txt +``` + +This installs Flask, SQLAlchemy, PyMySQL, **waitress** (the WSGI server IIS +launches) and **tzdata** (Windows has no IANA tz database; without it the +notifications plugin fails with "No time zone found with key America/New_York"). + +--- + +## 4. Secrets + connection (.env) + +Create `APP_ROOT\.env` (read by `wsgi.py` via `load_dotenv()`). Lock its ACLs to +the app-pool identity + admins. + +``` +FLASK_ENV=production +SECRET_KEY=<64+ random chars> +JWT_SECRET_KEY= +DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@:3306/shopdb_flask?charset=utf8mb4 +CORS_ORIGINS=http:// +``` + +Generate a key: `venv\Scripts\python -c "import secrets;print(secrets.token_urlsafe(64))"`. +Production **refuses to boot** if any of `SECRET_KEY`, `JWT_SECRET_KEY`, +`DATABASE_URL`, `CORS_ORIGINS` is missing or a dev default. + +--- + +## 5. Preflight (catch problems before installing) + +```powershell +$env:FLASK_APP="shopdb" +venv\Scripts\flask db-utils preflight +``` + +Checks Python, required env, DB connectivity, and the MySQL 5.6 index flags, and +prints exactly what to fix. Fix any **FAIL** before continuing. + +--- + +## 6. Schema + data + plugins + admin + +```powershell +$env:FLASK_APP="shopdb" + +venv\Scripts\flask db upgrade # creates every table (to head) +venv\Scripts\flask seed reference-data # statuses, machine/location/rel types +venv\Scripts\flask seed permissions +venv\Scripts\flask seed settings + +# enable the plugins this site tracks (registry is empty on a fresh box). +# usb + employees install DISABLED by default - enable them later in the wizard +# if the site wants those (they create extra tables). +foreach ($p in "computers","equipment","network","notifications","printers","knowledgebase","slides","warranty") { + venv\Scripts\flask plugin install $p +} + +# first admin (password generated + printed once - store it): +venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example.com +``` + +> Prefer no CLI? Skip `seed admin` (and even the seed steps): start the site, and +> the login page offers to **create the first admin** on a fresh instance, then +> the setup wizard can seed reference data. Either path works. + +--- + +## 7. IIS site + +1. Copy `deploy\windows\web.config` to `APP_ROOT\web.config`. If `APP_ROOT` is not + `C:\shopdb-flask`, fix the paths inside it. Create `APP_ROOT\logs`. +2. Create an app pool with **No Managed Code**: + ```powershell + Import-Module WebAdministration + New-WebAppPool -Name shopdbflask + Set-ItemProperty IIS:\AppPools\shopdbflask -Name managedRuntimeVersion -Value "" + ``` +3. Grant the app-pool identity access: + ```powershell + icacls APP_ROOT /grant "IIS AppPool\shopdbflask:(OI)(CI)RX" /T + icacls APP_ROOT\logs /grant "IIS AppPool\shopdbflask:(OI)(CI)M" /T + ``` +4. **Unlock the handler sections** (locked server-wide by default; without this + IIS returns **HTTP 500.19**): + ```powershell + %windir%\system32\inetsrv\appcmd unlock config /section:system.webServer/handlers + %windir%\system32\inetsrv\appcmd unlock config /section:system.webServer/httpPlatform + ``` +5. Create the site (own port; the classic ASP site can keep 8080): + ```powershell + New-Website -Name shopdb-flask -Port 8090 -PhysicalPath APP_ROOT -ApplicationPool shopdbflask + New-NetFirewallRule -DisplayName "shopdb-flask 8090" -Direction Inbound -Protocol TCP -LocalPort 8090 -Action Allow + Start-Website shopdb-flask + ``` + +IIS launches `waitress-serve --port=%HTTP_PLATFORM_PORT% wsgi:app` per the +web.config and reverse-proxies the site port to it. First request takes ~15s +(the app boots + connects to MySQL). + +> The `X-Forwarded-For` URL Rewrite rule in web.config is **commented out by +> default**. It needs the URL Rewrite module; with it active but the module +> absent, IIS returns 500.19. Install URL Rewrite, then uncomment the +> `` block, to record real client IPs in audit logs. + +--- + +## 8. Smoke test + first run + +```powershell +(Invoke-WebRequest http://localhost:8090/ -UseBasicParsing).StatusCode # 200 (SPA) +Invoke-WebRequest http://localhost:8090/api/auth/login -Method POST ` + -Body '{"username":"admin","password":""}' ` + -ContentType application/json -UseBasicParsing # 200 + token +``` + +Browse to `http://:8090`, sign in as the admin, and the **setup wizard** +walks through site name, features (per-plugin: create tables here vs connect a DB), +floor-map upload, and starter data. Multiple Flask apps can share one IIS box - +each gets its own site, app pool, port, and venv. + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +| --- | --- | +| `flask db upgrade` -> error **1071** | MySQL 5.6 without the step-1 flags (or server not restarted). | +| IIS **500.19** | handler sections not unlocked (step 7.4), or the `` block active without URL Rewrite. | +| **500** with an empty HttpPlatform log | app-pool identity can't read `APP_ROOT` / run the venv (step 7.3), or `.env` missing/invalid. | +| "No time zone found with key America/New_York" | `tzdata` not installed (`pip install tzdata`). | +| Nav missing Equipment/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. | +| ConfigError on boot | a required `.env` var missing or left at a dev default. | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3acf201..89ae0fa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -shopdb-flask is at `__contract_version__ = '0.2.0'` (pre-1.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs. +shopdb-flask is at `__contract_version__ = '0.5.0'` (pre-1.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs. ## Phase status @@ -12,7 +12,7 @@ shopdb-flask is at `__contract_version__ = '0.2.0'` (pre-1.0). This document cap | 3 - Manifest-first loader, shopdb.api namespace, auto-register blueprints | DONE | `6f085a1` | | 4 - Plugin scaffolding (`flask plugin new`) | DONE | `8eb9362` | | 5 - Alembic baseline, per-site deploy, ADRs to docs/adr | DONE | `d4e3ac9` | -| 6 - Polish (this phase) | IN PROGRESS | this commit | +| 6 - Multi-site distribution readiness (settings-driven branding/ServiceNow/floor plan, security closeout, docs + Docker frontend build, release engineering) | IN PROGRESS | this phase | ## What's left before tagging 1.0.0 @@ -26,6 +26,9 @@ shopdb-flask is at `__contract_version__ = '0.2.0'` (pre-1.0). This document cap ### Nice-to-have +- **Bundle the Roboto font locally.** `frontend/src/assets/style.css:2` imports Roboto from Google Fonts (`fonts.googleapis.com`). Air-gapped facilities have no route to that host, so the font silently falls back to a system font. Vendor the woff2 files into `frontend/src/assets/` and `@font-face` them locally so every site renders identically offline. +- **Full palette theming.** `brand_primary_color` is settings-driven, but the rest of the CSS palette (surfaces, borders, accents) is still hardcoded in `style.css`. A complete theming pass would expose the palette as CSS variables a site can override, not just the one primary color. +- **Frontend plugin contract.** The backend hook system has no Vue-side equivalent yet (routes/views still ship in core; nav is already backend-driven). See the must-have entry above; this is the design ADR that unblocks external plugins shipping their own UI. - `measuringtools` plugin built using the scaffold (validates the scaffold under realistic conditions). - Frontend scaffolding skill (the backend has `flask plugin new`; the frontend stub is currently manual copy-paste). - Marketplace listing site (PLUGINS.md is a one-pager; a proper listing with links to sister-site plugins becomes useful when there are more than three external plugins). diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md new file mode 100644 index 0000000..a3c5f98 --- /dev/null +++ b/docs/UPGRADE.md @@ -0,0 +1,112 @@ +# Upgrading an existing site + +This is the procedure for moving a running shopdb-flask instance to a newer +version. For a first-time install use [DEPLOY.md](DEPLOY.md) instead. + +Each site is single-tenant (ADR-004), so an upgrade touches only that site's +own stack. Read the ADRs added since your last update (`docs/adr/`) and the +`CHANGELOG.md` before starting; a breaking ADR may require coordinated work. + +## Step 0: Back up first + +Never upgrade without a fresh backup you have tested. Take a full database dump +and copy the `instance/` directory. See [BACKUP-RESTORE.md](BACKUP-RESTORE.md). + +```bash +# Docker: +docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask | gzip > pre-upgrade-$(date +%F).sql.gz +cp -a instance/ instance-backup-$(date +%F)/ +``` + +## Step 1: Get the new code / image + +```bash +git pull origin main +``` + +The application is distributed through the internal GE Aerospace Gitea; pull +from there. There is no external image registry. + +## Step 2: Rebuild + +The Docker image builds the Vue frontend in-image, so a container rebuild picks +up frontend changes automatically: + +```bash +docker compose build api +docker compose up -d api +``` + +Bare-metal / venv install: rebuild the frontend by hand and refresh Python +dependencies: + +```bash +source venv/bin/activate +pip install -r requirements.txt +cd frontend && npm ci && npm run build && cd .. +``` + +## Step 3: Apply migrations + +```bash +# Docker: +docker compose exec api flask db upgrade +# venv: +flask db upgrade +``` + +`flask db upgrade` applies any new migrations in the core Alembic chain. It is +idempotent; running it when already at head is a no-op. + +## Step 4: Re-seed permissions and settings + +New versions may add RBAC permissions or default Settings keys. Both seeders are +idempotent - they add anything missing and leave existing rows untouched, so +your site's customized values are preserved. + +```bash +# Docker: +docker compose exec api flask seed permissions +docker compose exec api flask seed settings +# venv: +flask seed permissions +flask seed settings +``` + +## Step 5: Restart + +```bash +docker compose restart api +# venv: restart your process manager, e.g. pm2 restart shopdb-flask-api shopdb-flask-ui +``` + +Confirm the app is healthy (login page renders, `/api/auth/login` returns a +`VALIDATION_ERROR` for an empty body rather than a 500). + +## Version-specific notes + +### Upgrading to v0.5.0 or later: bundled West Jefferson floor plan removed + +Versions before 0.5 shipped the West Jefferson facility floor-plan PNGs as the +map default (`/static/images/sitemap2025-light.png` and `-dark.png`). v0.5+ +removes those bundled PNGs and ships a generic placeholder SVG instead. + +If your instance's `map_blueprint_light` / `map_blueprint_dark` Settings still +point at `/static/images/sitemap2025-*`, the map will 404 those images after the +upgrade. Re-upload your own floor plan in **Settings > Map**. Uploaded floor +plans are stored under `instance/` and survive upgrades, so a site that already +uploaded its own plan is unaffected. Only instances still using the old bundled +default need to act. + +To check what your instance points at: + +```bash +docker compose exec api flask shell -c "from shopdb.core.models.setting import Setting; print(Setting.query.filter(Setting.key.like('map_blueprint%')).all())" +``` + +## See also + +- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - what to back up and how to restore +- [CONFIG.md](CONFIG.md) - environment variables and Setting keys +- [DEPLOY.md](DEPLOY.md) - first-time deploy runbook +- `CHANGELOG.md` - what changed in each release diff --git a/docs/adr/ADR-007-product-versioning-and-releases.md b/docs/adr/ADR-007-product-versioning-and-releases.md new file mode 100644 index 0000000..1588126 --- /dev/null +++ b/docs/adr/ADR-007-product-versioning-and-releases.md @@ -0,0 +1,115 @@ +# ADR-007: Product versioning and releases + +- **Status:** ACCEPTED +- **Date:** 2026-07-10 +- **Deciders:** cproudlock +- **Supersedes:** none + +## Context + +ADR-002 established semantic versioning for the plugin contract via +`__contract_version__` in `shopdb/__init__.py`. That number answers one +question only: is a given plugin compatible with this platform build. It +says nothing about the state of the product as a whole. A sister site +standing up its own instance needs a different answer: which release am I +running, and what changed since the last one. + +Until now the product had no version, no tags, no changelog, and no CI. +ADR-002's pinning model implicitly assumes that a downstream site can +pin a known-good build, but there were no tags to pin to. Adopters had no +release record to read before upgrading, and no automated gate confirming +that a given commit builds and passes tests. + +## Decision + +The product carries its own release version, separate from the plugin +contract version. + +1. **Product version** (`shopdb/__init__.py`): a single `__version__` + constant. This is the version of the shopdb-flask product as a whole. + It follows semantic versioning of the product's user-visible and + operator-visible behavior. It is deliberately NOT part of the + `shopdb.api` contract surface, so it is never re-exported through + `shopdb.api`; exporting it would itself be a contract change under + ADR-002. + +2. **Plugin contract version** (`__contract_version__`): unchanged from + ADR-002. It moves only when the plugin contract surface changes, per + the major/minor/patch rules in ADR-002. + +The two are distinct series with independent bump rules. They happen to +coincide at `0.5.0` for this release; that is a coincidence of timing, +not a coupling. A product release that changes no contract surface bumps +`__version__` while leaving `__contract_version__` fixed, and vice versa. + +3. **Release record** (`CHANGELOG.md`, repo root): the canonical, + human-readable record of what changed in each release, in + Keep-a-Changelog format. Every release has an entry; work in flight + accumulates under `## [Unreleased]`. + +4. **Git tags**: each product release is tagged `vX.Y.Z`, where `X.Y.Z` + matches `__version__` at the tagged commit. Tags are what ADR-002's + downstream-pinning model pins to. + +5. **Frontend version** (`frontend/package.json`): kept in lock-step with + `__version__` so the shipped single-page app reports the same product + version as the backend that serves it. + +### Release procedure + +To cut release `X.Y.Z`: + +1. Bump `__version__` in `shopdb/__init__.py` to `X.Y.Z`. +2. Bump `version` in `frontend/package.json` to the same `X.Y.Z`. +3. Move the accumulated `## [Unreleased]` notes in `CHANGELOG.md` into a + new `## [X.Y.Z] - YYYY-MM-DD` section, leaving a fresh empty + `## [Unreleased]` above it. +4. If the plugin contract surface changed this release, bump + `__contract_version__` per ADR-002 (independently of `__version__`). +5. Commit, then tag: `git tag -a vX.Y.Z -m "shopdb-flask X.Y.Z"`. +6. Push the commit and the tag. + +## Consequences + +### Positive + +- Adopters have a real release to name in bug reports and a changelog to + read before upgrading. +- ADR-002's pinning story finally has tags to pin to. +- Product and contract can evolve at their own pace without one dragging + the other into a misleading bump. + +### Negative / cost + +- Two version numbers to keep straight. The comment in + `shopdb/__init__.py` and this ADR exist to keep the distinction clear. +- Release discipline: the changelog and both version constants must be + updated together, or the tagged build misreports itself. + +### Neutral + +- CI (`.gitea/workflows/ci.yml`) runs the backend tests, the naming/style + gate, and the frontend build on push and PR. It is best-effort: Gitea + Actions availability on the host is unverified, so the workflow is + config-only until a runner is confirmed. + +## Alternatives considered + +1. **Reuse `__contract_version__` as the product version.** Conflates two + independent concerns; a docs-only or UI-only release would either + falsely bump the contract or leave the product looking unchanged. + Rejected. +2. **Calendar versioning for the product** (e.g. `2026.07.0`). Easy to + bump but poor at signaling breaking operator-facing changes. Rejected + for the same reasons ADR-002 rejected it for the contract. +3. **No product version, rely on git SHAs.** Opaque to adopters and + unpinnable in any human-meaningful way. Rejected. + +## References + +- ADR-001 (defines the contract surface) +- ADR-002 (plugin contract versioning; `__contract_version__` bump rules) +- `shopdb/__init__.py` (`__version__`, `__contract_version__`) +- `CHANGELOG.md` (release record) +- `frontend/package.json` (frontend version, kept in lock-step) +- `.gitea/workflows/ci.yml` (CI gate) diff --git a/docs/adr/README.md b/docs/adr/README.md index 748d117..4ccf89d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ Each ADR captures a single architectural decision: the context, the decision its | [004](ADR-004-deployment-topology.md) | Deployment topology (per-site instances) | ACCEPTED | | [005](ADR-005-equipment-vs-measuringtools.md) | Equipment vs measuringtools plugin scope | ACCEPTED | | [006](ADR-006-collector-contract.md) | Plugin collector contract pattern | ACCEPTED | +| [007](ADR-007-product-versioning-and-releases.md) | Product versioning and releases | ACCEPTED | ## Authoring diff --git a/frontend/index.html b/frontend/index.html index 4c7dc1d..e046b07 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ ShopDB + diff --git a/frontend/package.json b/frontend/package.json index 92d5290..4bc4551 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "shopdb-frontend", - "version": "1.0.0", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..d6de271 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + S + diff --git a/frontend/public/ge-monogram.svg b/frontend/public/ge-monogram.svg new file mode 100644 index 0000000..cb2214b --- /dev/null +++ b/frontend/public/ge-monogram.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/static/images/floorplan-placeholder.svg b/frontend/public/static/images/floorplan-placeholder.svg new file mode 100644 index 0000000..e33d54a --- /dev/null +++ b/frontend/public/static/images/floorplan-placeholder.svg @@ -0,0 +1,11 @@ + + + + + + + + + + Upload your facility floor plan in Settings > Map + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 8b32a97..1fc4985 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -808,6 +808,13 @@ export const settingsApi = { form.append('theme', theme) form.append('file', file) return api.post('/settings/map-blueprint', form, { headers: { 'Content-Type': 'multipart/form-data' } }) + }, + uploadBrandingLogo(kind, file) { + // kind is one of site/qr/badge/favicon; backend writes the matching setting + const form = new FormData() + form.append('kind', kind) + form.append('file', file) + return api.post('/settings/branding-logo', form, { headers: { 'Content-Type': 'multipart/form-data' } }) } } diff --git a/frontend/src/composables/mapConfig.js b/frontend/src/composables/mapConfig.js index 8ef8dce..f95c28f 100644 --- a/frontend/src/composables/mapConfig.js +++ b/frontend/src/composables/mapConfig.js @@ -1,15 +1,15 @@ // 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 West Jefferson sitemap so a fresh or offline -// install still renders. +// 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' -// Fallback defaults - the original hardcoded West Jefferson values. +// Fallback defaults - match the seeded map_blueprint_* setting defaults. const DEFAULTS = { - blueprintLight: '/static/images/sitemap2025-light.png', - blueprintDark: '/static/images/sitemap2025-dark.png', + blueprintLight: '/static/images/floorplan-placeholder.svg', + blueprintDark: '/static/images/floorplan-placeholder.svg', width: 3300, height: 2550 } diff --git a/frontend/src/main.js b/frontend/src/main.js index f1cb1fb..9e61988 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -5,6 +5,7 @@ import App from './App.vue' // Initialize theme on app load import './stores/theme' +import { applyBranding } from './utils/siteSettings' const app = createApp(App) @@ -12,3 +13,6 @@ 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/utils/qrTarget.js b/frontend/src/utils/qrTarget.js new file mode 100644 index 0000000..688e198 --- /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 index 2033c9e..024717b 100644 --- a/frontend/src/utils/siteSettings.js +++ b/frontend/src/utils/siteSettings.js @@ -33,5 +33,79 @@ export async function getSiteBaseUrl() { // Facility name shown on the shopfloor dashboard. export async function getFacilityName() { - return getSetting('facility_name', 'West Jefferson') + return getSetting('facility_name', 'ShopDB') +} + +// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark. +export async function getSiteLogo() { + return 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 (value === undefined || value === null) ? '/ge-monogram.svg' : value +} + +// Logo printed on equipment inspection badges. +export async function getBadgeLogo() { + return getSetting('badge_logo', '/ge-aerospace-logo.svg') +} + +// Browser-tab favicon. Empty = keep the shipped /favicon.svg. +export async function getFavicon() { + return getSetting('site_favicon', '') +} + +// Brand primary color override. Empty = built-in palette from style.css. +export async function getBrandPrimaryColor() { + return getSetting('brand_primary_color', '') +} + +// 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') +} + +// 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 primaryColor = await getBrandPrimaryColor() + if (primaryColor) { + document.documentElement.style.setProperty('--primary', primaryColor) + } } diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index abdcfc8..f933e46 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -2,8 +2,8 @@
@@ -89,12 +92,24 @@ import { import { useAuthStore } from '../stores/auth' import { currentTheme, toggleTheme } from '../stores/theme' import { dashboardApi, notificationsApi } from '../api' +import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings' const router = useRouter() const authStore = useAuthStore() const searchQuery = ref('') const navItems = ref([]) const activeNotifications = ref([]) +const facilityName = ref('ShopDB') +const siteLogo = ref('/ge-aerospace-logo.svg') +const servicenowConfig = ref({ enabled: true, searchUrl: '' }) + +function getTicketSearchUrl(ticketnumber) { + // Null when ServiceNow is disabled or no search template is set: the + // ticket number renders as plain text instead of a link. + const config = servicenowConfig.value + if (!ticketnumber || !config.enabled || !config.searchUrl) return null + return config.searchUrl.replace('{ticket}', encodeURIComponent(ticketnumber)) +} // Map backend icon names to Lucide components const iconMap = { @@ -161,6 +176,10 @@ function buildNavItems(items) { } onMounted(async () => { + getFacilityName().then(name => { facilityName.value = name }) + getSiteLogo().then(logo => { siteLogo.value = logo }) + getServicenowUrls().then(config => { servicenowConfig.value = config }) + try { const response = await dashboardApi.navigation() navItems.value = buildNavItems(response.data.data || []) diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index ab0daa2..57cd4a4 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -1,7 +1,7 @@ - - {{ n.ticketnumber }} - + @@ -155,9 +158,12 @@ {{ formatDateTime(n.starttime) }} - - {{ n.ticketnumber }} - + @@ -179,10 +185,13 @@ diff --git a/frontend/src/views/print/EquipmentBadge.vue b/frontend/src/views/print/EquipmentBadge.vue index 5a34fcb..a9aeb9d 100644 --- a/frontend/src/views/print/EquipmentBadge.vue +++ b/frontend/src/views/print/EquipmentBadge.vue @@ -29,6 +29,7 @@ import { ref, computed, onMounted, nextTick } from 'vue' import { useRoute } from 'vue-router' import { equipmentApi } from '../../api' +import { getBadgeLogo } from '@/utils/siteSettings' import JsBarcode from 'jsbarcode' const route = useRoute() @@ -36,7 +37,7 @@ const loading = ref(true) const equipment = ref(null) const barcodeEl = ref(null) -const geLogo = '/images/applications/GE-Logo.png' +const geLogo = ref('/ge-aerospace-logo.svg') const isInspection = computed(() => { if (!equipment.value) return false @@ -57,6 +58,7 @@ const imageUrl = computed(() => { }) onMounted(async () => { + getBadgeLogo().then(logo => { geLogo.value = logo }) try { const response = await equipmentApi.get(route.params.id) equipment.value = response.data.data diff --git a/frontend/src/views/print/PrinterQRBatch.vue b/frontend/src/views/print/PrinterQRBatch.vue index 16a18ee..5877b25 100644 --- a/frontend/src/views/print/PrinterQRBatch.vue +++ b/frontend/src/views/print/PrinterQRBatch.vue @@ -66,7 +66,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue' import { printersApi } from '../../api' import { renderQrDataUrl } from './qrLogo' -import { getSiteBaseUrl } from '@/utils/siteSettings' +import { buildQrUrl } from '@/utils/qrTarget' const printers = ref([]) const selectedPrinters = ref([]) @@ -106,7 +106,6 @@ watch(selectedPrinters, async () => { }, { deep: true }) async function generateQRCodes() { - const baseUrl = await getSiteBaseUrl() const next = {} for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) { const page = pages.value[pageIdx] @@ -114,7 +113,15 @@ async function generateQRCodes() { const printer = page[idx] if (!printer) continue const pos = idx + 1 - const qrUrl = `${baseUrl}/printers/${printer.printer?.printerid || printer.assetid}` + const detailId = printer.printer?.printerid || printer.assetid + const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, { + printerid: printer.printer?.printerid || '', + assetid: printer.assetid || '', + assetnumber: printer.assetnumber || '', + serialnumber: printer.serialnumber || '', + ip: getIp(printer) || '', + hostname: printer.printer?.windowsname || printer.name || '', + }) next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl) } } diff --git a/frontend/src/views/print/PrinterQRSingle.vue b/frontend/src/views/print/PrinterQRSingle.vue index c72571a..b87a660 100644 --- a/frontend/src/views/print/PrinterQRSingle.vue +++ b/frontend/src/views/print/PrinterQRSingle.vue @@ -48,7 +48,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue' import { useRoute } from 'vue-router' import { printersApi } from '../../api' import { renderQrDataUrl } from './qrLogo' -import { getSiteBaseUrl } from '@/utils/siteSettings' +import { buildQrUrl } from '@/utils/qrTarget' const route = useRoute() const loading = ref(true) @@ -87,8 +87,16 @@ watch(position, async () => { async function generateQR() { if (!printer.value) return - const baseUrl = await getSiteBaseUrl() - const qrUrl = `${baseUrl}/printers/${printer.value.printer?.printerid || printer.value.assetid}` + const p = printer.value + const detailId = p.printer?.printerid || p.assetid + const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, { + printerid: p.printer?.printerid || '', + assetid: p.assetid || '', + assetnumber: p.assetnumber || '', + serialnumber: p.serialnumber || '', + ip: ipAddress.value || '', + hostname: p.printer?.windowsname || p.name || '', + }) qrImage.value = await renderQrDataUrl(qrUrl) } diff --git a/frontend/src/views/print/USBLabelBatch.vue b/frontend/src/views/print/USBLabelBatch.vue index aab6109..ef8ece4 100644 --- a/frontend/src/views/print/USBLabelBatch.vue +++ b/frontend/src/views/print/USBLabelBatch.vue @@ -1,374 +1,425 @@ - - - - - + + + + + diff --git a/frontend/src/views/print/qrLogo.js b/frontend/src/views/print/qrLogo.js index e66a61a..8d865fc 100644 --- a/frontend/src/views/print/qrLogo.js +++ b/frontend/src/views/print/qrLogo.js @@ -2,18 +2,26 @@ // 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 -function loadLogo() { +// 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 = () => resolve(null) - img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG) + 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 }) } @@ -31,18 +39,21 @@ function drawLogoOverlay(canvas, logo) { canvasContext.fill() if (logo) { - canvasContext.drawImage(logo, 0, 0, 32.5, 32, x, y, logoSize, logoSize) + canvasContext.drawImage(logo, x, y, logoSize, logoSize) } } -// Render a QR for `url` to a PNG data URL with the GE monogram composited in. -// Returns the data URL string, or '' on failure. +// 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 logo = await loadLogo() + const qrLogoUrl = await getQrLogo() const canvas = document.createElement('canvas') try { await QRCode.toCanvas(canvas, url, { width: 144, margin: 0, errorCorrectionLevel: 'H' }) - drawLogoOverlay(canvas, logo) + if (qrLogoUrl) { + const logo = await loadLogo(qrLogoUrl) + drawLogoOverlay(canvas, logo) + } return canvas.toDataURL('image/png') } catch (err) { console.error('QR error:', err) diff --git a/frontend/src/views/printers/PrinterForm.vue b/frontend/src/views/printers/PrinterForm.vue index 2999031..906ad70 100644 --- a/frontend/src/views/printers/PrinterForm.vue +++ b/frontend/src/views/printers/PrinterForm.vue @@ -299,6 +299,7 @@ import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue' import { currentTheme } from '../../stores/theme' import { useIdentifierFlags } from '../../composables/identifierSettings' import { apiError } from '../../utils/apiError' +import { getPrinterHostnameTemplate } from '../../utils/siteSettings' const { isEnabled } = useIdentifierFlags() @@ -385,11 +386,13 @@ function getModelShortDesc(modelNumber) { return match ? match[1] : modelNumber.substring(0, 5) } -// Auto-generate hostname from IP address -function generateHostname(ip) { +// Auto-generate hostname from IP address using the site template ({ip} = +// dash-separated IP). +async function generateHostname(ip) { if (!ip) return '' const ipDashed = ip.replace(/\./g, '-') - return `Printer-${ipDashed}.printer.geaerospace.net` + const template = await getPrinterHostnameTemplate() + return template.replace('{ip}', ipDashed) } // Auto-generate Windows name (machinenumber) @@ -431,9 +434,9 @@ function generateWindowsName() { } // Watch IP address and auto-generate hostname -watch(() => form.value.ipaddress, (newIp) => { +watch(() => form.value.ipaddress, async (newIp) => { if (!manualHostname.value && newIp) { - form.value.hostname = generateHostname(newIp) + form.value.hostname = await generateHostname(newIp) } }) diff --git a/frontend/src/views/settings/SiteSettings.vue b/frontend/src/views/settings/SiteSettings.vue index 00d5f69..ae48730 100644 --- a/frontend/src/views/settings/SiteSettings.vue +++ b/frontend/src/views/settings/SiteSettings.vue @@ -10,8 +10,8 @@
No site settings found.
@@ -42,12 +42,23 @@ const error = ref('') const LABELS = { site_base_url: 'Site URL / FQDN', facility_name: 'Facility Name', - pc_access_domain: 'PC Access Domain' + pc_access_domain: 'PC Access Domain', + employeeid_pattern: 'Employee ID Pattern', + printer_hostname_template: 'Printer Hostname Template' } function prettyLabel(key) { return LABELS[key] || key } +// Inline help for site fields that need more than the stored description. +const HELP = { + employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.', + printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net' +} +function fieldHelp(key) { + return HELP[key] || '' +} + async function load() { loading.value = true try { diff --git a/frontend/src/views/settings/SystemSettings.vue b/frontend/src/views/settings/SystemSettings.vue index 7cd84a3..46aa0c1 100644 --- a/frontend/src/views/settings/SystemSettings.vue +++ b/frontend/src/views/settings/SystemSettings.vue @@ -157,6 +157,198 @@ {{ dellMessage }}
+ +
+

ServiceNow

+

+ Wire global search and ticket links to your ServiceNow instance. When + enabled, matching ticket numbers become clickable links and can trigger + a smart-redirect from global search. Defaults ship for GE; change them + for your site. +

+ +
+ +
+ + +
+ + + +
+

Branding

+ +
+

+ Replace the shipped GE logos with your own site branding. Each logo can + be uploaded, or set to a path/URL directly. Leave blank to use the + bundled default. +

+ +
+ +
+ +
+ +
+
+
+ + +
+

Printing & Labels

+ +
+

+ Where printed QR codes point. Leave a target blank to link to the + asset's own page on this instance, or enter a custom URL template + with {placeholder} substitution. +

+ +
+ +
+ +
+ +
+ +
+ +
+
@@ -670,7 +862,9 @@ import { apiError } from '../../utils/apiError' // Section tabs - one section visible at a time to avoid a long scroll. The // search box filters tabs (and, while searching, shows every matching section). const SETTINGS_TABS = [ - { key: 'integrations', label: 'Integrations', keywords: 'zabbix toner supply printer monitoring api integration' }, + { key: 'integrations', label: 'Integrations', keywords: 'zabbix toner supply printer monitoring api integration servicenow ticket incident change dell warranty' }, + { key: 'branding', label: 'Branding', keywords: 'branding logo site qr badge favicon color brand primary theme image' }, + { key: 'printing', label: 'Printing & Labels', keywords: 'printing qr label barcode usb printer target url template sticker' }, { key: 'email', label: 'Email / SMTP', keywords: 'email smtp mail notifications alerts tls from recipients' }, { key: 'audit', label: 'Audit & Logging', keywords: 'audit log retention history' }, { key: 'auth', label: 'Authentication', keywords: 'auth saml sso login users idp' }, @@ -719,6 +913,22 @@ const settings = reactive({ 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: '', + // Printing and labels + qr_target_printer: '', + qr_target_usb: '', + usb_label_style: 'barcode', // Email smtp_enabled: false, smtp_host: '', @@ -746,6 +956,23 @@ const settings = reactive({ saml_admin_group: '' }) +// Branding logo upload widgets. kind maps to the backend endpoint; key is the +// setting the resulting URL is stored under. +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: 'Equipment badge logo', accept: 'image/*', + placeholder: '/ge-aerospace-logo.svg', + hint: 'Logo printed on equipment 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). const identifierRows = [ @@ -797,6 +1024,7 @@ 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 testingEmail = ref(false) const error = ref('') const success = ref('') @@ -938,6 +1166,26 @@ async function uploadBlueprint(theme, event) { } } +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 = '' + } +} + async function toggleSetting(key) { const newValue = !settings[key] await saveSetting(key, newValue) @@ -1349,6 +1597,9 @@ onMounted(loadSettings) .identifier-matrix .identifier-name { color: var(--text); } +.color-input-row { display: flex; align-items: center; gap: 0.75rem; } +.color-input-row input[type="color"] { width: 48px; height: 34px; padding: 2px; cursor: pointer; } +.color-input-row input[type="text"] { max-width: 160px; } .map-upload-row { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.4rem; } .map-thumb { height: 40px; border: 1px solid var(--border); border-radius: 4px; background: #fff; } .map-thumb-dark { background: #222; } diff --git a/frontend/src/views/settings/settingsNav.js b/frontend/src/views/settings/settingsNav.js index 3a516ca..4e4e385 100644 --- a/frontend/src/views/settings/settingsNav.js +++ b/frontend/src/views/settings/settingsNav.js @@ -7,8 +7,9 @@ export const settingsGroups = [ { title: 'Site & Facility', cards: [ - { to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, and PC access domain' }, + { 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/system?tab=map', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint and dimensions' }, + { to: '/settings/system?tab=branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and primary brand color' }, ], }, { @@ -72,7 +73,7 @@ export const settingsGroups = [ { title: 'System', cards: [ - { to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations, identifiers, search, and PC-type mapping' }, + { to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations (ServiceNow, Zabbix), branding, identifiers, search, and PC-type mapping' }, { to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' }, ], }, diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index ba3abea..5c5acf5 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -3,7 +3,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query +from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess @@ -42,7 +42,7 @@ def list_computer_types(): @jwt_required(optional=True) def get_computer_type(type_id: int): """Get a single computer type.""" - t = ComputerType.query.get(type_id) + t = db.session.get(ComputerType, type_id) if not t: return error_response( @@ -97,7 +97,7 @@ def create_computer_type(): @require_permission('computers.edit') def update_computer_type(type_id: int): """Update a computer type.""" - t = ComputerType.query.get(type_id) + t = db.session.get(ComputerType, type_id) if not t: return error_response( @@ -131,7 +131,7 @@ def update_computer_type(type_id: int): @require_permission('computers.delete') def delete_computer_type(type_id: int): """Delete a computer type. Refused if any PC still uses it.""" - t = ComputerType.query.get(type_id) + 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() @@ -183,7 +183,7 @@ def create_protocol(): @jwt_required() @require_permission('computers.edit') def update_protocol(protocol_id): - p = AccessProtocol.query.get(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 {} @@ -202,7 +202,7 @@ def update_protocol(protocol_id): @jwt_required() @require_permission('computers.edit') def delete_protocol(protocol_id): - p = AccessProtocol.query.get(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. @@ -215,13 +215,19 @@ def delete_protocol(protocol_id): return success_response(message='Protocol deleted') -def _computer_access_links(comp): +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.""" - from shopdb.core.api.settings import get_cached_settings - settings = get_cached_settings() - domain = (settings.get('pc_access_domain') or '').strip() + 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 = '' @@ -375,10 +381,11 @@ def list_computers(): # 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) + item['accessmethods'] = _computer_access_links(comp, domain=accessdomain) data.append(item) return paginated_response(data, page, per_page, total) @@ -388,7 +395,7 @@ def list_computers(): @jwt_required(optional=True) def get_computer(computer_id: int): """Get a single computer with full details.""" - comp = Computer.query.get(computer_id) + comp = db.session.get(Computer, computer_id) if not comp: return error_response( @@ -562,7 +569,7 @@ def create_computer(): @require_permission('computers.edit') def update_computer(computer_id: int): """Update computer (both Asset and Computer records).""" - comp = Computer.query.get(computer_id) + comp = db.session.get(Computer, computer_id) if not comp: return error_response( @@ -662,7 +669,7 @@ def update_computer(computer_id: int): @require_permission('computers.delete') def delete_computer(computer_id: int): """Delete (soft delete) computer.""" - comp = Computer.query.get(computer_id) + comp = db.session.get(Computer, computer_id) if not comp: return error_response( @@ -691,7 +698,7 @@ def delete_computer(computer_id: int): @jwt_required(optional=True) def get_installed_apps(computer_id: int): """Get all installed applications for a computer.""" - comp = Computer.query.get(computer_id) + comp = db.session.get(Computer, computer_id) if not comp: return error_response( @@ -715,7 +722,7 @@ def get_installed_apps(computer_id: int): @require_permission('computers.create') def add_installed_app(computer_id: int): """Add an installed application to a computer.""" - comp = Computer.query.get(computer_id) + comp = db.session.get(Computer, computer_id) if not comp: return error_response( @@ -731,7 +738,7 @@ def add_installed_app(computer_id: int): appid = data['appid'] # Validate app exists - if not Application.query.get(appid): + if not db.session.get(Application, appid): return error_response(ErrorCodes.NOT_FOUND, f'Application {appid} not found', http_code=404) # Check for duplicate @@ -805,7 +812,7 @@ def report_status(computer_id: int): This endpoint can be called periodically by a client agent to update status information. """ - comp = Computer.query.get(computer_id) + comp = db.session.get(Computer, computer_id) if not comp: return error_response( @@ -817,8 +824,8 @@ def report_status(computer_id: int): data = request.get_json() or {} # Update status fields - from datetime import datetime - comp.lastreporteddate = datetime.utcnow() + from datetime import datetime, timezone + comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None) if 'loggedinuser' in data: comp.loggedinuser = data['loggedinuser'] diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 6600d43..ed957e7 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -97,7 +97,7 @@ class ComputersPlugin(BasePlugin): def apply_collector_payload(self, payload: Dict) -> Dict: """Idempotent upsert of a PC from a collector payload (by hostname).""" - from datetime import datetime + from datetime import datetime, timezone from shopdb.api import ( Asset, Application, Communication, CommunicationType, Vendor, Model, OperatingSystem, @@ -136,7 +136,7 @@ class ComputersPlugin(BasePlugin): elif machinenumber and comp.asset: comp.asset.assetnumber = machinenumber - comp.lastreporteddate = datetime.utcnow() + comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None) if payload.get('lastboottime'): try: comp.lastboottime = datetime.fromisoformat( diff --git a/plugins/employees/api/routes.py b/plugins/employees/api/routes.py index 53007fd..4e8c774 100644 --- a/plugins/employees/api/routes.py +++ b/plugins/employees/api/routes.py @@ -22,7 +22,7 @@ from shopdb.api import ( employee_connection, require_role, ) -from shopdb.core.models import Setting +from shopdb.api import Setting from ..models import DirectoryEmployee @@ -112,7 +112,7 @@ def lookup_employee(sso): ) if _selfhosted(): - emp = DirectoryEmployee.query.get(int(sso)) + 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) @@ -247,7 +247,7 @@ def create_directory_employee(): sso = int(fields['sso']) except (ValueError, TypeError): return error_response(ErrorCodes.VALIDATION_ERROR, 'sso must be numeric') - if DirectoryEmployee.query.get(sso): + 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']) @@ -263,7 +263,7 @@ def update_directory_employee(sso): guard = _require_selfhosted() if guard: return guard - emp = DirectoryEmployee.query.get(sso) + 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 {}) @@ -285,7 +285,7 @@ def delete_directory_employee(sso): guard = _require_selfhosted() if guard: return guard - emp = DirectoryEmployee.query.get(sso) + 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) @@ -326,7 +326,7 @@ def import_directory(): team = row.get('team') or None role = row.get('role') or None picture = row.get('picture') or None - emp = DirectoryEmployee.query.get(sso) + 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 diff --git a/plugins/equipment/api/routes.py b/plugins/equipment/api/routes.py index 9beb55e..c03281a 100644 --- a/plugins/equipment/api/routes.py +++ b/plugins/equipment/api/routes.py @@ -42,7 +42,7 @@ def list_equipment_types(): @jwt_required(optional=True) def get_equipment_type(type_id: int): """Get a single equipment type.""" - t = EquipmentType.query.get(type_id) + t = db.session.get(EquipmentType, type_id) if not t: return error_response( @@ -96,7 +96,7 @@ def create_equipment_type(): @require_permission('equipment.edit') def update_equipment_type(type_id: int): """Update an equipment type.""" - t = EquipmentType.query.get(type_id) + t = db.session.get(EquipmentType, type_id) if not t: return error_response( @@ -130,7 +130,7 @@ def update_equipment_type(type_id: int): @require_permission('equipment.delete') def delete_equipment_type(type_id: int): """Delete an equipment type. Refused if any asset still uses it.""" - t = EquipmentType.query.get(type_id) + t = db.session.get(EquipmentType, type_id) if not t: return error_response(ErrorCodes.NOT_FOUND, 'Equipment type not found', http_code=404) inuse = Equipment.query.filter_by(equipmenttypeid=type_id).count() @@ -225,7 +225,7 @@ def list_equipment(): @jwt_required(optional=True) def get_equipment(equipment_id: int): """Get a single equipment item with full details.""" - equip = Equipment.query.get(equipment_id) + equip = db.session.get(Equipment, equipment_id) if not equip: return error_response( @@ -354,7 +354,7 @@ def create_equipment(): @require_permission('equipment.edit') def update_equipment(equipment_id: int): """Update equipment (both Asset and Equipment records).""" - equip = Equipment.query.get(equipment_id) + equip = db.session.get(Equipment, equipment_id) if not equip: return error_response( @@ -425,7 +425,7 @@ def update_equipment(equipment_id: int): @require_permission('equipment.delete') def delete_equipment(equipment_id: int): """Delete (soft delete) equipment.""" - equip = Equipment.query.get(equipment_id) + equip = db.session.get(Equipment, equipment_id) if not equip: return error_response( diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py index 64a63c6..17110fa 100644 --- a/plugins/knowledgebase/api/routes.py +++ b/plugins/knowledgebase/api/routes.py @@ -102,7 +102,7 @@ def get_stats(): @jwt_required(optional=True) def get_article(link_id: int): """Get a single knowledge base article.""" - article = KnowledgeBase.query.get(link_id) + 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) @@ -123,7 +123,7 @@ def get_article(link_id: int): @jwt_required(optional=True) def track_click(link_id: int): """Increment click counter and return the URL to redirect to.""" - article = KnowledgeBase.query.get(link_id) + 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) @@ -152,7 +152,7 @@ def create_article(): # Validate application if provided if data.get('appid'): - app = Application.query.get(data['appid']) + app = db.session.get(Application, data['appid']) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) @@ -175,7 +175,7 @@ def create_article(): @require_permission('kb.edit') def update_article(link_id: int): """Update a knowledge base article.""" - article = KnowledgeBase.query.get(link_id) + article = db.session.get(KnowledgeBase, link_id) if not article: return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) @@ -186,7 +186,7 @@ def update_article(link_id: int): # Validate application if being changed if 'appid' in data and data['appid']: - app = Application.query.get(data['appid']) + app = db.session.get(Application, data['appid']) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) @@ -204,7 +204,7 @@ def update_article(link_id: int): @require_permission('kb.delete') def delete_article(link_id: int): """Delete (deactivate) a knowledge base article.""" - article = KnowledgeBase.query.get(link_id) + article = db.session.get(KnowledgeBase, link_id) if not article: return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 4d24fc4..88c2df4 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -42,7 +42,7 @@ def list_network_device_types(): @jwt_required(optional=True) def get_network_device_type(type_id: int): """Get a single network device type.""" - t = NetworkDeviceType.query.get(type_id) + t = db.session.get(NetworkDeviceType, type_id) if not t: return error_response( @@ -96,7 +96,7 @@ def create_network_device_type(): @require_permission('network.edit') def update_network_device_type(type_id: int): """Update a network device type.""" - t = NetworkDeviceType.query.get(type_id) + t = db.session.get(NetworkDeviceType, type_id) if not t: return error_response( @@ -130,7 +130,7 @@ def update_network_device_type(type_id: int): @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 = NetworkDeviceType.query.get(type_id) + 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() @@ -238,7 +238,7 @@ def list_network_devices(): @jwt_required(optional=True) def get_network_device(device_id: int): """Get a single network device with full details.""" - netdev = NetworkDevice.query.get(device_id) + netdev = db.session.get(NetworkDevice, device_id) if not netdev: return error_response( @@ -393,7 +393,7 @@ def create_network_device(): @require_permission('network.edit') def update_network_device(device_id: int): """Update network device (both Asset and NetworkDevice records).""" - netdev = NetworkDevice.query.get(device_id) + netdev = db.session.get(NetworkDevice, device_id) if not netdev: return error_response( @@ -471,7 +471,7 @@ def update_network_device(device_id: int): @require_permission('network.delete') def delete_network_device(device_id: int): """Delete (soft delete) network device.""" - netdev = NetworkDevice.query.get(device_id) + netdev = db.session.get(NetworkDevice, device_id) if not netdev: return error_response( @@ -581,7 +581,7 @@ def list_vlans(): @jwt_required(optional=True) def get_vlan(vlan_id: int): """Get a single VLAN with its subnets.""" - vlan = VLAN.query.get(vlan_id) + vlan = db.session.get(VLAN, vlan_id) if not vlan: return error_response( @@ -644,7 +644,7 @@ def create_vlan(): @require_permission('network.edit') def update_vlan(vlan_id: int): """Update a VLAN.""" - vlan = VLAN.query.get(vlan_id) + vlan = db.session.get(VLAN, vlan_id) if not vlan: return error_response( @@ -690,7 +690,7 @@ def update_vlan(vlan_id: int): @require_permission('network.delete') def delete_vlan(vlan_id: int): """Delete (soft delete) a VLAN.""" - vlan = VLAN.query.get(vlan_id) + vlan = db.session.get(VLAN, vlan_id) if not vlan: return error_response( @@ -767,7 +767,7 @@ def list_subnets(): @jwt_required(optional=True) def get_subnet(subnet_id: int): """Get a single subnet.""" - subnet = Subnet.query.get(subnet_id) + subnet = db.session.get(Subnet, subnet_id) if not subnet: return error_response( @@ -809,7 +809,7 @@ def create_subnet(): # Validate VLAN if provided if data.get('vlanid'): - if not VLAN.query.get(data['vlanid']): + if not db.session.get(VLAN, data['vlanid']): return error_response( ErrorCodes.VALIDATION_ERROR, f"VLAN with ID {data['vlanid']} not found" @@ -850,7 +850,7 @@ def create_subnet(): @require_permission('network.edit') def update_subnet(subnet_id: int): """Update a subnet.""" - subnet = Subnet.query.get(subnet_id) + subnet = db.session.get(Subnet, subnet_id) if not subnet: return error_response( @@ -901,7 +901,7 @@ def update_subnet(subnet_id: int): @require_permission('network.delete') def delete_subnet(subnet_id: int): """Delete (soft delete) a subnet.""" - subnet = Subnet.query.get(subnet_id) + subnet = db.session.get(Subnet, subnet_id) if not subnet: return error_response( diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index 93dd3a8..3eea00d 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -227,7 +227,7 @@ def create_notification_type(): @require_permission('notifications.create') def update_notification_type(type_id: int): """Update a notification type, including its auto-expiry rule.""" - t = NotificationType.query.get(type_id) + 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) @@ -290,7 +290,7 @@ def list_notifications(): # Current filter (active based on dates) if request.args.get('current', 'false').lower() == 'true': - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) query = query.filter( Notification.starttime <= now, db.or_( @@ -317,7 +317,7 @@ def list_notifications(): @notifications_bp.route('/', methods=['GET']) def get_notification(notification_id: int): """Get a single notification.""" - n = Notification.query.get(notification_id) + n = db.session.get(Notification, notification_id) if not n: return error_response( @@ -345,7 +345,7 @@ def create_notification(): return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required') # Parse dates - starttime = datetime.utcnow() + 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') @@ -364,7 +364,7 @@ def create_notification(): # 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 = NotificationType.query.get(data['notificationtypeid']) + ntype = db.session.get(NotificationType, data['notificationtypeid']) if ntype: endtime = _auto_endtime(ntype, starttime) @@ -394,7 +394,7 @@ def create_notification(): @require_permission('notifications.edit') def update_notification(notification_id: int): """Update a notification.""" - n = Notification.query.get(notification_id) + n = db.session.get(Notification, notification_id) if not n: return error_response( @@ -440,7 +440,7 @@ def update_notification(notification_id: int): except ValueError: return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format') else: - n.starttime = datetime.utcnow() + 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') @@ -461,7 +461,7 @@ def update_notification(notification_id: int): @require_permission('notifications.delete') def delete_notification(notification_id: int): """Delete (soft delete) a notification.""" - n = Notification.query.get(notification_id) + n = db.session.get(Notification, notification_id) if not n: return error_response( @@ -485,7 +485,7 @@ def get_active_notifications(): """ Get currently active notifications for display. """ - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) from datetime import timedelta lookahead = now + timedelta(days=10) @@ -551,7 +551,7 @@ def get_calendar_events(): @notifications_bp.route('/dashboard/summary', methods=['GET']) def dashboard_summary(): """Get notifications dashboard summary.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) # Total active notifications total_active = Notification.query.filter( @@ -643,7 +643,7 @@ def get_shopfloor_notifications(): """ from datetime import timedelta - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) business_unit = request.args.get('businessunit') # Base query for shopfloor notifications diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py index 7fe8576..a776af5 100644 --- a/plugins/notifications/models/notification.py +++ b/plugins/notifications/models/notification.py @@ -1,6 +1,6 @@ """Notifications plugin models - adapted to existing database schema.""" -from datetime import datetime +from datetime import datetime, timezone from shopdb.api import db @@ -93,7 +93,7 @@ class Notification(db.Model): @property def is_current(self): """Check if notification is currently active based on dates.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) if not self.isactive: return False if self.starttime and now < self.starttime: diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py index 3f7ca8b..cc66d48 100644 --- a/plugins/notifications/plugin.py +++ b/plugins/notifications/plugin.py @@ -145,10 +145,10 @@ class NotificationsPlugin(BasePlugin): def stats(): """Show notification statistics.""" from flask import current_app - from datetime import datetime + from datetime import datetime, timezone with current_app.app_context(): - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) total = Notification.query.filter( Notification.isactive == True diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index bd434e8..a4eaf1d 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -54,7 +54,7 @@ def list_printer_types(): @jwt_required(optional=True) def get_printer_type(type_id: int): """Get a single printer type.""" - t = PrinterType.query.get(type_id) + t = db.session.get(PrinterType, type_id) if not t: return error_response( @@ -108,7 +108,7 @@ def create_printer_type(): @require_permission('printers.edit') def update_printer_type(type_id: int): """Update a printer type.""" - t = PrinterType.query.get(type_id) + 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) @@ -132,7 +132,7 @@ def update_printer_type(type_id: int): @require_permission('printers.delete') def delete_printer_type(type_id: int): """Delete a printer type. Refused if any printer still uses it.""" - t = PrinterType.query.get(type_id) + 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() @@ -182,7 +182,7 @@ def create_driver(): @jwt_required() @require_permission('printers.edit') def update_driver(driver_id): - d = PrinterDriver.query.get(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 {} @@ -199,7 +199,7 @@ def update_driver(driver_id): @jwt_required() @require_permission('printers.delete') def delete_driver(driver_id): - d = PrinterDriver.query.get(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) @@ -398,7 +398,7 @@ def pc_default_printer(): @jwt_required(optional=True) def get_printer(printer_id: int): """Get a single printer with full details.""" - printer = Printer.query.get(printer_id) + printer = db.session.get(Printer, printer_id) if not printer: return error_response( @@ -551,7 +551,7 @@ def create_printer(): @require_permission('printers.edit') def update_printer(printer_id: int): """Update printer (both Asset and Printer records).""" - printer = Printer.query.get(printer_id) + printer = db.session.get(Printer, printer_id) if not printer: return error_response( @@ -626,7 +626,7 @@ def update_printer(printer_id: int): @require_permission('printers.delete') def delete_printer(printer_id: int): """Delete (soft delete) printer.""" - printer = Printer.query.get(printer_id) + printer = db.session.get(Printer, printer_id) if not printer: return error_response( @@ -650,7 +650,7 @@ def delete_printer(printer_id: int): @jwt_required(optional=True) def get_printer_supplies(printer_id: int): """Get supply levels from Zabbix (real-time lookup).""" - printer = Printer.query.get(printer_id) + printer = db.session.get(Printer, printer_id) if not printer: return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) @@ -776,7 +776,7 @@ def _get_low_supplies_data(): location_name = None if asset.locationid: from shopdb.api import Location - loc = Location.query.get(asset.locationid) + loc = db.session.get(Location, asset.locationid) if loc: location_name = loc.locationname @@ -1031,7 +1031,7 @@ def list_supply_models(): @jwt_required(optional=True) def list_model_supplies(modelnumberid: int): """List all supplies mapped to a model.""" - model = Model.query.get(modelnumberid) + model = db.session.get(Model, modelnumberid) if not model: return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) @@ -1053,7 +1053,7 @@ def list_model_supplies(modelnumberid: int): @require_permission('printers.create') def create_model_supply(modelnumberid: int): """Add a supply to a model.""" - model = Model.query.get(modelnumberid) + model = db.session.get(Model, modelnumberid) if not model: return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) @@ -1094,7 +1094,7 @@ def create_model_supply(modelnumberid: int): @require_permission('printers.edit') def update_model_supply(modelsupplyid: int): """Update a model supply.""" - supply = ModelSupply.query.get(modelsupplyid) + supply = db.session.get(ModelSupply, modelsupplyid) if not supply: return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) @@ -1139,7 +1139,7 @@ def update_model_supply(modelsupplyid: int): @require_permission('printers.delete') def delete_model_supply(modelsupplyid: int): """Delete a model supply.""" - supply = ModelSupply.query.get(modelsupplyid) + supply = db.session.get(ModelSupply, modelsupplyid) if not supply: return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) diff --git a/plugins/slides/api/routes.py b/plugins/slides/api/routes.py index 999df1b..d61ef70 100644 --- a/plugins/slides/api/routes.py +++ b/plugins/slides/api/routes.py @@ -197,7 +197,7 @@ def delete_slides(surface): def update_slide(surface, slideid): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') - row = TvSlide.query.get(slideid) + 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 {} diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py index 2863d1d..27aa987 100644 --- a/plugins/usb/api/routes.py +++ b/plugins/usb/api/routes.py @@ -37,7 +37,7 @@ from shopdb.api import ( get_pagination_params, require_permission, ) -from shopdb.core.models import Setting +from shopdb.api import Setting from . import selfhosted diff --git a/plugins/usb/api/selfhosted.py b/plugins/usb/api/selfhosted.py index 1c51065..4009e2f 100644 --- a/plugins/usb/api/selfhosted.py +++ b/plugins/usb/api/selfhosted.py @@ -16,7 +16,7 @@ History rows are synthesized from usbcheckouts (each row = a check-out event and if returned, a check-in event). """ -from datetime import datetime +from datetime import datetime, timezone from shopdb.api import ( db, success_response, error_response, ErrorCodes, @@ -36,7 +36,7 @@ def _resolve_name(sso): try: from plugins.employees.models import DirectoryEmployee if sso and str(sso).isdigit(): - emp = DirectoryEmployee.query.get(int(sso)) + emp = db.session.get(DirectoryEmployee, int(sso)) if emp: return f'{emp.firstname} {emp.lastname}'.strip() except Exception: @@ -188,7 +188,7 @@ def checkout_device(device_id, data): if device.ischeckedout: return error_response(ErrorCodes.CONFLICT, 'Device is already checked out', http_code=409) name = _resolve_name(badge) - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, machineid=0, sso=badge, checkoutname=name, checkouttime=now, checkoutreason=data.get('reason'))) @@ -215,7 +215,7 @@ def checkin_device(device_id, data): .filter_by(usbdeviceid=device.usbdeviceid, checkintime=None) .order_by(USBCheckout.checkouttime.desc()).first()) if open_checkout: - open_checkout.checkintime = datetime.utcnow() + open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None) open_checkout.waswiped = bool(data.get('sanitized')) open_checkout.checkinnotes = data.get('notes') device.ischeckedout = False diff --git a/plugins/usb/models/usb_device.py b/plugins/usb/models/usb_device.py index f37490b..e3367ec 100644 --- a/plugins/usb/models/usb_device.py +++ b/plugins/usb/models/usb_device.py @@ -1,9 +1,14 @@ """USB device plugin models.""" -from datetime import datetime +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. @@ -125,7 +130,7 @@ class USBCheckout(BaseModel): checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user') # Checkout details - checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + checkouttime = db.Column(db.DateTime, nullable=False, default=_utcnow) checkintime = db.Column(db.DateTime, nullable=True) # Metadata @@ -147,7 +152,7 @@ class USBCheckout(BaseModel): @property def duration_days(self): """Get duration of checkout in days.""" - end = self.checkintime or datetime.utcnow() + end = self.checkintime or datetime.now(timezone.utc).replace(tzinfo=None) delta = end - self.checkouttime return delta.days diff --git a/plugins/warranty/api/routes.py b/plugins/warranty/api/routes.py index bed5156..dab775e 100644 --- a/plugins/warranty/api/routes.py +++ b/plugins/warranty/api/routes.py @@ -5,7 +5,7 @@ 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 +from datetime import date, datetime, timezone from flask import Blueprint, request from flask_jwt_extended import jwt_required @@ -46,7 +46,7 @@ def _warranty_payload(warranty, today=None): data = warranty.to_dict(today) assets = [] for link in warranty.links: - asset = Asset.query.get(link.assetid) + asset = db.session.get(Asset, link.assetid) if asset: assets.append(_asset_summary(asset)) data['assets'] = assets @@ -60,7 +60,7 @@ def _apply_links(warranty, assetids): 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 Asset.query.get(assetid): + if db.session.get(Asset, assetid): warranty.links.append(WarrantyAsset(assetid=assetid)) for assetid in set(existing) - wanted: warranty.links.remove(existing[assetid]) @@ -100,7 +100,7 @@ def warranties_for_asset(assetid): today = date.today() items = [] for link in links: - w = Warranty.query.get(link.warrantyid) + w = db.session.get(Warranty, link.warrantyid) if w and w.isactive: items.append(_warranty_payload(w, today)) return success_response(items) @@ -109,7 +109,7 @@ def warranties_for_asset(assetid): @warranty_bp.route('/', methods=['GET']) @jwt_required(optional=True) def get_warranty(warrantyid): - warranty = Warranty.query.get(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)) @@ -142,7 +142,7 @@ def create_warranty(): @jwt_required() @require_permission('warranty.edit') def update_warranty(warrantyid): - warranty = Warranty.query.get(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 {} @@ -172,7 +172,7 @@ def update_warranty(warrantyid): @jwt_required() @require_permission('warranty.delete') def delete_warranty(warrantyid): - warranty = Warranty.query.get(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) @@ -188,7 +188,7 @@ def delete_warranty(warrantyid): @jwt_required() @require_permission('warranty.edit') def refresh_warranty(warrantyid): - warranty = Warranty.query.get(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) @@ -205,7 +205,7 @@ def refresh_warranty(warrantyid): warranty.startdate = _parse_date(result['startdate']) if result.get('enddate'): warranty.enddate = _parse_date(result['enddate']) - warranty.lastcheckeddate = datetime.utcnow() + warranty.lastcheckeddate = datetime.now(timezone.utc).replace(tzinfo=None) db.session.commit() return success_response(_warranty_payload(warranty), message='Warranty refreshed') @@ -232,7 +232,7 @@ def sync_dell(): covered = set() if not recheck_all: for link in WarrantyAsset.query.all(): - w = Warranty.query.get(link.warrantyid) + w = db.session.get(Warranty, link.warrantyid) if w and w.isactive and w.enddate: covered.add(link.assetid) @@ -259,7 +259,7 @@ def sync_dell(): return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400) created = updated = matched = 0 - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) for tag, assetids in by_tag.items(): found = results.get(tag) if not found: @@ -269,7 +269,7 @@ def sync_dell(): # 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 = Warranty.query.get(link.warrantyid) + candidate = db.session.get(Warranty, link.warrantyid) if candidate and candidate.provider == 'dell': existing = candidate break diff --git a/plugins/warranty/services/providers.py b/plugins/warranty/services/providers.py index 094e0b9..092fdb7 100644 --- a/plugins/warranty/services/providers.py +++ b/plugins/warranty/services/providers.py @@ -17,7 +17,7 @@ import requests from flask import current_app from shopdb.api import db -from shopdb.core.models import Setting +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. diff --git a/scripts/migration/one-offs/README.md b/scripts/migration/one-offs/README.md new file mode 100644 index 0000000..67f2c83 --- /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/sql/add_recertification_type.sql b/scripts/migration/one-offs/add_recertification_type.sql similarity index 100% rename from sql/add_recertification_type.sql rename to scripts/migration/one-offs/add_recertification_type.sql diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 981bc10..3127590 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -20,6 +20,12 @@ from .plugins import plugin_manager # a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction. __contract_version__ = '0.5.0' +# Product release version (see ADR-007). The product version and the +# plugin-contract version above are distinct series with independent +# bump rules; they happen to coincide at 0.5.0. Not part of the +# shopdb.api contract surface, so it is not re-exported there. +__version__ = '0.5.0' + def create_app(config_name: str = None) -> Flask: """ diff --git a/shopdb/config.py b/shopdb/config.py index c75e9a2..4f8b22d 100644 --- a/shopdb/config.py +++ b/shopdb/config.py @@ -85,6 +85,14 @@ class Config: 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 @@ -105,6 +113,9 @@ 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}, diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py index 565dbda..95dfde4 100644 --- a/shopdb/core/api/applications.py +++ b/shopdb/core/api/applications.py @@ -115,7 +115,7 @@ def list_applications(): @jwt_required(optional=True) def get_application(app_id: int): """Get a single application with details.""" - app = Application.query.get(app_id) + app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) @@ -188,7 +188,7 @@ def create_application(): @require_permission('applications.edit') def update_application(app_id: int): """Update an application.""" - app = Application.query.get(app_id) + app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) @@ -233,7 +233,7 @@ def update_application(app_id: int): @require_permission('applications.delete') def delete_application(app_id: int): """Delete (deactivate) an application.""" - app = Application.query.get(app_id) + app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) @@ -253,7 +253,7 @@ def delete_application(app_id: int): @jwt_required(optional=True) def list_versions(app_id: int): """List all versions for an application.""" - app = Application.query.get(app_id) + app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) @@ -266,7 +266,7 @@ def list_versions(app_id: int): @require_permission('applications.create') def create_version(app_id: int): """Create a new version for an application.""" - app = Application.query.get(app_id) + app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) @@ -300,7 +300,7 @@ def create_version(app_id: int): @jwt_required(optional=True) def list_installed_machines(app_id: int): """List all computers that have this application installed.""" - app = Application.query.get(app_id) + app = db.session.get(Application, app_id) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) @@ -344,7 +344,7 @@ def list_machine_applications(machine_id: int): return err Computer, ComputerInstalledApp = models - comp = Computer.query.get(machine_id) + comp = db.session.get(Computer, machine_id) if not comp: return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404) @@ -362,7 +362,7 @@ def install_application(machine_id: int): return err Computer, ComputerInstalledApp = models - comp = Computer.query.get(machine_id) + comp = db.session.get(Computer, machine_id) if not comp: return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404) @@ -370,7 +370,7 @@ def install_application(machine_id: int): if not data or not data.get('appid'): return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required') - app = Application.query.get(data['appid']) + app = db.session.get(Application, data['appid']) if not app: return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py index 7603aee..38d0122 100644 --- a/shopdb/core/api/assets.py +++ b/shopdb/core/api/assets.py @@ -46,7 +46,7 @@ def list_asset_types(): @jwt_required(optional=True) def get_asset_type(type_id: int): """Get a single asset type.""" - t = AssetType.query.get(type_id) + t = db.session.get(AssetType, type_id) if not t: return error_response( @@ -96,7 +96,7 @@ def create_asset_type(): 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 = AssetType.query.get(type_id) + 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 {} @@ -134,7 +134,7 @@ def list_asset_statuses(): @jwt_required(optional=True) def get_asset_status(status_id: int): """Get a single asset status.""" - s = AssetStatus.query.get(status_id) + s = db.session.get(AssetStatus, status_id) if not s: return error_response( @@ -180,7 +180,7 @@ def create_asset_status(): @require_permission('assets.edit') def update_asset_status(status_id: int): """Update an asset status.""" - s = AssetStatus.query.get(status_id) + s = db.session.get(AssetStatus, status_id) if not s: return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', http_code=404) @@ -209,7 +209,7 @@ def update_asset_status(status_id: int): @require_permission('assets.delete') def delete_asset_status(status_id: int): """Delete an asset status. Refused if any asset still uses it.""" - s = AssetStatus.query.get(status_id) + s = db.session.get(AssetStatus, status_id) if not s: return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', http_code=404) @@ -285,7 +285,7 @@ def _rel_type_dict(t): @require_permission('assets.edit') def update_relationship_type(type_id: int): """Update a relationship type.""" - t = RelationshipType.query.get(type_id) + 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 {} @@ -305,7 +305,7 @@ def update_relationship_type(type_id: int): @require_permission('assets.delete') def delete_relationship_type(type_id: int): """Delete a relationship type. Refused if any relationship still uses it.""" - t = RelationshipType.query.get(type_id) + 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() @@ -412,7 +412,7 @@ def get_asset(asset_id: int): Query parameters: - include_type_data: Include category-specific extension data (default: true) """ - asset = Asset.query.get(asset_id) + asset = db.session.get(Asset, asset_id) if not asset: return error_response( @@ -450,7 +450,7 @@ def create_asset(): ) # Validate foreign keys exist - if not AssetType.query.get(data['assettypeid']): + if not db.session.get(AssetType, data['assettypeid']): return error_response( ErrorCodes.VALIDATION_ERROR, f"Asset type with ID {data['assettypeid']} not found" @@ -480,7 +480,7 @@ def create_asset(): @require_permission('assets.edit') def update_asset(asset_id: int): """Update an asset.""" - asset = Asset.query.get(asset_id) + asset = db.session.get(Asset, asset_id) if not asset: return error_response( @@ -521,7 +521,7 @@ def update_asset(asset_id: int): @require_permission('assets.delete') def delete_asset(asset_id: int): """Delete (soft delete) an asset.""" - asset = Asset.query.get(asset_id) + asset = db.session.get(Asset, asset_id) if not asset: return error_response( @@ -568,7 +568,7 @@ def get_asset_relationships(asset_id: int): Returns both outgoing (source) and incoming (target) relationships. """ - asset = Asset.query.get(asset_id) + asset = db.session.get(Asset, asset_id) if not asset: return error_response( @@ -628,11 +628,11 @@ def create_asset_relationship(): type_id = data['relationshiptypeid'] # Validate assets exist - if not Asset.query.get(source_id): + 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 Asset.query.get(target_id): + 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 RelationshipType.query.get(type_id): + 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 @@ -667,7 +667,7 @@ def create_asset_relationship(): @require_permission('assets.delete') def delete_asset_relationship(rel_id: int): """Delete an asset relationship.""" - rel = AssetRelationship.query.get(rel_id) + rel = db.session.get(AssetRelationship, rel_id) if not rel: return error_response( @@ -972,7 +972,7 @@ def get_asset_communications(asset_id: int): """Get all communications for an asset.""" from shopdb.core.models import Communication - asset = Asset.query.get(asset_id) + asset = db.session.get(Asset, asset_id) if not asset: return error_response( diff --git a/shopdb/core/api/auditlogs.py b/shopdb/core/api/auditlogs.py index cac20b5..1fcefe4 100644 --- a/shopdb/core/api/auditlogs.py +++ b/shopdb/core/api/auditlogs.py @@ -103,7 +103,7 @@ def get_entity_history(entitytype: str, entityid: int): def get_stats(): """Get audit log statistics.""" from sqlalchemy import func - from datetime import datetime, timedelta + from datetime import datetime, timedelta, timezone # Actions by type actions = db_func_count_by(AuditLog.action) @@ -112,7 +112,7 @@ def get_stats(): entities = db_func_count_by(AuditLog.entitytype) # Recent activity (last 7 days) - week_ago = datetime.utcnow() - timedelta(days=7) + 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) diff --git a/shopdb/core/api/auth.py b/shopdb/core/api/auth.py index 8ef4268..478455e 100644 --- a/shopdb/core/api/auth.py +++ b/shopdb/core/api/auth.py @@ -1,8 +1,9 @@ """Authentication API endpoints.""" -from datetime import datetime, timedelta +import time +from datetime import datetime, timedelta, timezone -from flask import Blueprint, request +from flask import Blueprint, request, current_app from flask_jwt_extended import ( create_access_token, create_refresh_token, @@ -12,7 +13,7 @@ from flask_jwt_extended import ( ) from werkzeug.security import check_password_hash -from shopdb.extensions import db +from shopdb.extensions import db, cache from shopdb.core.models import User from shopdb.utils.responses import success_response, error_response, ErrorCodes @@ -24,6 +25,39 @@ 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(): """ @@ -44,6 +78,13 @@ def login(): } } """ + 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'): @@ -72,7 +113,9 @@ def login(): if user: user.failedlogins = (user.failedlogins or 0) + 1 if user.failedlogins >= MAX_FAILED_LOGINS: - user.lockeduntil = datetime.utcnow() + timedelta(minutes=LOCKOUT_MINUTES) + # 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( diff --git a/shopdb/core/api/businessunits.py b/shopdb/core/api/businessunits.py index cdf50e6..3b572d3 100644 --- a/shopdb/core/api/businessunits.py +++ b/shopdb/core/api/businessunits.py @@ -49,7 +49,7 @@ def list_businessunits(): @jwt_required(optional=True) def get_businessunit(bu_id: int): """Get a single business unit.""" - bu = BusinessUnit.query.get(bu_id) + bu = db.session.get(BusinessUnit, bu_id) if not bu: return error_response( @@ -100,7 +100,7 @@ def create_businessunit(): @require_role('admin') def update_businessunit(bu_id: int): """Update a business unit.""" - bu = BusinessUnit.query.get(bu_id) + bu = db.session.get(BusinessUnit, bu_id) if not bu: return error_response( @@ -134,7 +134,7 @@ def update_businessunit(bu_id: int): @require_role('admin') def delete_businessunit(bu_id: int): """Delete (deactivate) a business unit.""" - bu = BusinessUnit.query.get(bu_id) + bu = db.session.get(BusinessUnit, bu_id) if not bu: return error_response( diff --git a/shopdb/core/api/collector.py b/shopdb/core/api/collector.py index 39a8528..c582b31 100644 --- a/shopdb/core/api/collector.py +++ b/shopdb/core/api/collector.py @@ -6,7 +6,7 @@ API key (not JWT) for unattended scripts. Writes the asset/computer model (ADR-001), not the retired Machine model. """ -from datetime import datetime +from datetime import datetime, timezone from functools import wraps from flask import Blueprint, request, current_app @@ -37,7 +37,9 @@ def require_api_key(f): """Require API key authentication.""" @wraps(f) def decorated(*args, **kwargs): - api_key = request.headers.get('X-API-Key') or request.args.get('api_key') + # Header only. Querystring api_key was dropped so keys do not land in + # access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md). + api_key = request.headers.get('X-API-Key') expected_key = current_app.config.get('COLLECTOR_API_KEY') if not expected_key: @@ -138,7 +140,8 @@ def generic_collect(pluginname): if not expected_key: return error_response(ErrorCodes.INTERNAL_ERROR, 'Collector API key not configured', http_code=500) - api_key = request.headers.get('X-API-Key') or request.args.get('api_key') + # Header only (querystring fallback dropped, see require_api_key). + api_key = request.headers.get('X-API-Key') if api_key != expected_key: return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', http_code=401) @@ -161,12 +164,17 @@ def generic_collect(pluginname): 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 as 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, str(exc), http_code=500) + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Internal error processing collector payload', + http_code=500) action = outcome.get('action', 'noop') AuditLog.log( @@ -217,7 +225,7 @@ def update_pc_info(): f'PC with hostname {hostname} not found', http_code=404) - comp.lastreporteddate = datetime.utcnow() + comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None) if data.get('lastboottime'): boot = _parse_boot(data['lastboottime']) @@ -326,7 +334,7 @@ def pc_heartbeat(): updated = 0 not_found = [] - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) for hostname in hostnames: comp = _find_pc(hostname) if comp: @@ -359,7 +367,7 @@ def bulk_update(): updated = 0 not_found = [] errors = [] - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) for pc_data in pcs: hostname = pc_data.get('hostname') @@ -380,8 +388,11 @@ def bulk_update(): if boot: comp.lastboottime = boot updated += 1 - except Exception as exc: - errors.append({'hostname': hostname, 'error': str(exc)}) + 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() @@ -399,7 +410,7 @@ def collector_status(): """Check collector API status.""" return success_response({ 'status': 'ok', - 'timestamp': datetime.utcnow().isoformat(), + 'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), 'endpoints': [ 'POST /api/collector/', 'GET /api/collector/_schemas', diff --git a/shopdb/core/api/customfields.py b/shopdb/core/api/customfields.py index 4350f88..fd40940 100644 --- a/shopdb/core/api/customfields.py +++ b/shopdb/core/api/customfields.py @@ -68,7 +68,7 @@ def create_field(): 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 AssetType.query.get(assettypeid): + if not db.session.get(AssetType, assettypeid): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown assettypeid') datatype = data.get('datatype') or 'text' @@ -112,7 +112,7 @@ def create_field(): @jwt_required() @require_role('admin') def update_field(fieldid): - field = CustomField.query.get(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 {} @@ -137,7 +137,7 @@ def update_field(fieldid): @jwt_required() @require_role('admin') def delete_field(fieldid): - field = CustomField.query.get(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. @@ -172,7 +172,7 @@ def _fields_with_values(asset): @jwt_required(optional=True) def get_asset_fields(assetid): """Active custom fields for an asset's type, merged with its values.""" - asset = Asset.query.get(assetid) + 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)) @@ -183,7 +183,7 @@ def get_asset_fields(assetid): @require_role('admin') def save_asset_fields(assetid): """Upsert values for an asset. Body: {values: {fieldid: value, ...}}.""" - asset = Asset.query.get(assetid) + 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 {} diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py index eac3f3d..9bd7fa1 100644 --- a/shopdb/core/api/dashboard.py +++ b/shopdb/core/api/dashboard.py @@ -185,6 +185,8 @@ def get_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' @@ -194,5 +196,5 @@ def health_check(): return success_response({ 'status': 'ok' if db_status == 'healthy' else 'degraded', 'database': db_status, - 'version': '1.0.0' + 'version': __version__ }) diff --git a/shopdb/core/api/dashboarddefaults.py b/shopdb/core/api/dashboarddefaults.py index 5b89665..23fdc6b 100644 --- a/shopdb/core/api/dashboarddefaults.py +++ b/shopdb/core/api/dashboarddefaults.py @@ -10,6 +10,7 @@ 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__) @@ -64,6 +65,7 @@ def list_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 {} @@ -74,7 +76,7 @@ def create_default(): return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required') if not businessunitid: return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunitid is required') - if not BusinessUnit.query.get(businessunitid): + 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(): @@ -94,15 +96,16 @@ def create_default(): @dashboarddefaults_bp.route('/', methods=['PUT']) @jwt_required() +@require_role('admin') def update_default(default_id): """Update a mapping.""" - default = DashboardDefault.query.get(default_id) + 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 BusinessUnit.query.get(data['businessunitid']): + 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'] @@ -117,9 +120,10 @@ def update_default(default_id): @dashboarddefaults_bp.route('/', methods=['DELETE']) @jwt_required() +@require_role('admin') def delete_default(default_id): """Delete (deactivate) a mapping.""" - default = DashboardDefault.query.get(default_id) + 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 diff --git a/shopdb/core/api/locations.py b/shopdb/core/api/locations.py index 243c690..9bae915 100644 --- a/shopdb/core/api/locations.py +++ b/shopdb/core/api/locations.py @@ -68,7 +68,7 @@ def create_location_type(): @jwt_required() @require_role('admin') def update_location_type(type_id): - t = LocationType.query.get(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 {} @@ -87,7 +87,7 @@ def update_location_type(type_id): @jwt_required() @require_role('admin') def delete_location_type(type_id): - t = LocationType.query.get(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() @@ -130,7 +130,7 @@ def list_locations(): @jwt_required(optional=True) def get_location(location_id: int): """Get a single location.""" - loc = Location.query.get(location_id) + loc = db.session.get(Location, location_id) if not loc: return error_response( @@ -183,7 +183,7 @@ def create_location(): @require_role('admin') def update_location(location_id: int): """Update a location.""" - loc = Location.query.get(location_id) + loc = db.session.get(Location, location_id) if not loc: return error_response( @@ -219,7 +219,7 @@ def update_location(location_id: int): @require_role('admin') def delete_location(location_id: int): """Delete (deactivate) a location.""" - loc = Location.query.get(location_id) + loc = db.session.get(Location, location_id) if not loc: return error_response( diff --git a/shopdb/core/api/machinetypes.py b/shopdb/core/api/machinetypes.py index 976c7f9..61f15a2 100644 --- a/shopdb/core/api/machinetypes.py +++ b/shopdb/core/api/machinetypes.py @@ -47,7 +47,7 @@ def list_machinetypes(): @jwt_required(optional=True) def get_machinetype(type_id: int): """Get a single machine type.""" - mt = MachineType.query.get(type_id) + mt = db.session.get(MachineType, type_id) if not mt: return error_response( @@ -94,7 +94,7 @@ def create_machinetype(): @require_role('admin') def update_machinetype(type_id: int): """Update a machine type.""" - mt = MachineType.query.get(type_id) + mt = db.session.get(MachineType, type_id) if not mt: return error_response( @@ -129,7 +129,7 @@ def update_machinetype(type_id: int): @require_role('admin') def delete_machinetype(type_id: int): """Delete (deactivate) a machine type.""" - mt = MachineType.query.get(type_id) + mt = db.session.get(MachineType, type_id) if not mt: return error_response( diff --git a/shopdb/core/api/models.py b/shopdb/core/api/models.py index 2c05d14..2cd50e9 100644 --- a/shopdb/core/api/models.py +++ b/shopdb/core/api/models.py @@ -56,7 +56,7 @@ def list_models(): @jwt_required(optional=True) def get_model(model_id: int): """Get a single model.""" - m = Model.query.get(model_id) + m = db.session.get(Model, model_id) if not m: return error_response( @@ -115,7 +115,7 @@ def create_model(): @require_role('admin') def update_model(model_id: int): """Update a model.""" - m = Model.query.get(model_id) + m = db.session.get(Model, model_id) if not m: return error_response( @@ -141,7 +141,7 @@ def update_model(model_id: int): @require_role('admin') def delete_model(model_id: int): """Delete (deactivate) a model.""" - m = Model.query.get(model_id) + m = db.session.get(Model, model_id) if not m: return error_response( diff --git a/shopdb/core/api/operatingsystems.py b/shopdb/core/api/operatingsystems.py index 355ddb8..3960182 100644 --- a/shopdb/core/api/operatingsystems.py +++ b/shopdb/core/api/operatingsystems.py @@ -44,7 +44,7 @@ def list_operatingsystems(): @jwt_required(optional=True) def get_operatingsystem(os_id: int): """Get a single operating system.""" - os = OperatingSystem.query.get(os_id) + os = db.session.get(OperatingSystem, os_id) if not os: return error_response( @@ -95,7 +95,7 @@ def create_operatingsystem(): @require_role('admin') def update_operatingsystem(os_id: int): """Update an operating system.""" - os = OperatingSystem.query.get(os_id) + os = db.session.get(OperatingSystem, os_id) if not os: return error_response( @@ -121,7 +121,7 @@ def update_operatingsystem(os_id: int): @require_role('admin') def delete_operatingsystem(os_id: int): """Delete (deactivate) an operating system.""" - os = OperatingSystem.query.get(os_id) + os = db.session.get(OperatingSystem, os_id) if not os: return error_response( diff --git a/shopdb/core/api/reports.py b/shopdb/core/api/reports.py index ae5bf51..ec62736 100644 --- a/shopdb/core/api/reports.py +++ b/shopdb/core/api/reports.py @@ -2,7 +2,7 @@ import csv import io -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from flask import Blueprint, request, Response from flask_jwt_extended import jwt_required @@ -84,7 +84,7 @@ def equipment_by_type(): return success_response({ 'report': 'equipment_by_type', - 'generated': datetime.utcnow().isoformat(), + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), 'data': data, 'total': total }) @@ -143,7 +143,7 @@ def assets_by_status(): return success_response({ 'report': 'assets_by_status', - 'generated': datetime.utcnow().isoformat(), + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), 'data': data, 'total': total }) @@ -200,7 +200,7 @@ def kb_popularity(): return success_response({ 'report': 'kb_popularity', - 'generated': datetime.utcnow().isoformat(), + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), 'data': data, 'total': len(data) }) @@ -222,7 +222,7 @@ def warranty_status(): - assettypeid: Filter by asset type - format: 'json' (default) or 'csv' """ - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) expiring_threshold = now + timedelta(days=90) # Try to get warranty data from equipment or machines @@ -298,7 +298,7 @@ def warranty_status(): return success_response({ 'report': 'warranty_status', - 'generated': datetime.utcnow().isoformat(), + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), 'data': data, 'summary': { 'expired': data['expired']['count'], @@ -344,7 +344,7 @@ def software_compliance(): if not required_apps: return success_response({ 'report': 'software_compliance', - 'generated': datetime.utcnow().isoformat(), + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), 'data': [], 'message': 'No required applications defined' }) @@ -422,7 +422,7 @@ def software_compliance(): return success_response({ 'report': 'software_compliance', - 'generated': datetime.utcnow().isoformat(), + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), 'data': data, 'total': len(data) }) @@ -507,7 +507,7 @@ def asset_inventory(): csv_output = io.StringIO() csv_output.write("Asset Inventory Report\n") - csv_output.write(f"Generated: {datetime.utcnow().isoformat()}\n\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") @@ -532,7 +532,7 @@ def asset_inventory(): return success_response({ 'report': 'asset_inventory', - 'generated': datetime.utcnow().isoformat(), + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), 'data': data, 'total': total }) @@ -599,7 +599,7 @@ def pc_relationships(): return success_response({ 'report': 'pc_relationships', - 'generated': datetime.utcnow().isoformat(), + 'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), 'data': data, 'total': len(data) }) diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py index 0bab4db..a86a88c 100644 --- a/shopdb/core/api/search.py +++ b/shopdb/core/api/search.py @@ -4,7 +4,7 @@ import re import ipaddress import logging -from datetime import datetime +from datetime import datetime, timezone from flask import Blueprint, request, current_app from flask_jwt_extended import jwt_required from sqlalchemy.orm import joinedload @@ -14,6 +14,7 @@ from shopdb.core.models import ( Application, Setting, Asset, AssetType, Communication, Vendor, Model ) +from shopdb.core.api.settings import get_cached_settings from shopdb.utils.responses import success_response logger = logging.getLogger(__name__) @@ -32,22 +33,74 @@ def _require_enabled(name): if pm and not pm.registry.is_enabled(name): raise ImportError(f'{name} plugin disabled') -# ServiceNOW URL template -SERVICENOW_URL = ( - 'https://geit.service-now.com/now/nav/ui/search/' +# 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/' - 'back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui' ) +SERVICENOW_PREFIXES_DEFAULT = 'GEINC,GECHG,GERIT,GESCT' +EMPLOYEEID_PATTERN_DEFAULT = r'^\d{9}$' -def _classify_query(query): +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(re.match(r'^\d{9}$', query)), - 'is_servicenow': bool(re.match(r'^(GEINC|GECHG|GERIT|GESCT)\d+', query, re.IGNORECASE)), - 'servicenow_prefix': re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE).group(1) if re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE) else None, + '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)), } @@ -393,7 +446,7 @@ def _search_notifications(query, search_term): ) ).order_by(Notification.starttime.desc()).limit(15).all() - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) for notif in notifications: base_relevance = 20 if notif.ticketnumber and query.lower() == notif.ticketnumber.lower(): @@ -671,12 +724,13 @@ def global_search(): 'message': 'Search query too long' }) - classification = _classify_query(query) + 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 = SERVICENOW_URL.format(ticket=quote(query)) + servicenow_url = integrations['servicenow_url'].format(ticket=quote(query)) return success_response({ 'results': [], 'query': query, diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index a81bf20..102c070 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -1,559 +1,742 @@ -"""Settings API routes.""" - -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, 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'} - - -def _map_dir(): - return os.path.join(current_app.instance_path, 'maps') - -# 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 = '********' - -# 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 = ['equipment', 'computer', 'printer', 'network_device'] - -# 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', - 'equipment': 'Equipment', - 'computer': 'PCs', - 'printer': 'Printers', - 'network_device': 'Network Devices', - '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('', 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() - 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) - - 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('', 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 the West - # Jefferson sitemap so an un-reconfigured install still renders. - mapdefaults = [ - { - 'key': 'map_blueprint_light', - 'value': '/static/images/sitemap2025-light.png', - 'valuetype': 'string', - 'category': 'map', - 'description': 'Floor-map blueprint image (light theme) for this facility' - }, - { - 'key': 'map_blueprint_dark', - 'value': '/static/images/sitemap2025-dark.png', - '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': 'West Jefferson', - 'valuetype': 'string', - 'category': 'site', - 'description': 'Facility name shown on the shopfloor dashboard header' - }, - { - '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.' - }, - ] - - # Collector pc-type -> ComputerType mapping is computers-plugin domain; - # the plugin seeds pctypemap_ settings on install. - defaults = sitedefaults + identifierdefaults + searchdefaults + mapdefaults + [ - # 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') +"""Settings API routes.""" + +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, 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 = '********' + +# 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 = ['equipment', 'computer', 'printer', 'network_device'] + +# 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', + 'equipment': 'Equipment', + 'computer': 'PCs', + 'printer': 'Printers', + 'network_device': 'Network Devices', + '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() + 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) + + 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('', 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': '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.' + }, + ] + + # 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 equipment 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)' + }, + ] + + # 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)." + }, + ] + + # 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/users.py b/shopdb/core/api/users.py index 754252c..6a23b0d 100644 --- a/shopdb/core/api/users.py +++ b/shopdb/core/api/users.py @@ -7,17 +7,16 @@ from werkzeug.security import generate_password_hash from shopdb.extensions import db from shopdb.core.models import User, Role, Permission, AuditLog 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.""" - if not current_user.hasrole('admin'): - return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403) - users = User.query.order_by(User.username).all() return success_response([user_to_dict(u) for u in users]) @@ -26,10 +25,11 @@ def list_users(): @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 = User.query.get(userid) + user = db.session.get(User, userid) if not user: return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) @@ -38,11 +38,9 @@ def get_user(userid: int): @users_bp.route('', methods=['POST']) @jwt_required() +@require_role('admin') def create_user(): """Create a new user.""" - if not current_user.hasrole('admin'): - return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403) - data = request.get_json() if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required') @@ -90,10 +88,11 @@ def create_user(): @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 = User.query.get(userid) + user = db.session.get(User, userid) if not user: return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) @@ -120,7 +119,7 @@ def update_user(userid: int): changes['lastname'] = {'old': user.lastname, 'new': data['lastname']} user.lastname = data['lastname'] - # Admin-only fields + # 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: @@ -156,15 +155,13 @@ def update_user(userid: int): @users_bp.route('/', methods=['DELETE']) @jwt_required() +@require_role('admin') def delete_user(userid: int): """Delete a user.""" - if not current_user.hasrole('admin'): - return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403) - if current_user.userid == userid: return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account') - user = User.query.get(userid) + user = db.session.get(User, userid) if not user: return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) @@ -225,11 +222,9 @@ def list_roles(): @users_bp.route('/roles', methods=['POST']) @jwt_required() +@require_role('admin') def create_role(): """Create a new role.""" - if not current_user.hasrole('admin'): - return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403) - data = request.get_json() if not data or not data.get('rolename'): return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required') @@ -263,12 +258,10 @@ def create_role(): @users_bp.route('/roles/', methods=['PUT']) @jwt_required() +@require_role('admin') def update_role(roleid: int): """Update a role.""" - if not current_user.hasrole('admin'): - return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403) - - role = Role.query.get(roleid) + role = db.session.get(Role, roleid) if not role: return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404) @@ -308,12 +301,10 @@ def update_role(roleid: int): @users_bp.route('/roles/', methods=['DELETE']) @jwt_required() +@require_role('admin') def delete_role(roleid: int): """Delete a role.""" - if not current_user.hasrole('admin'): - return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403) - - role = Role.query.get(roleid) + role = db.session.get(Role, roleid) if not role: return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404) diff --git a/shopdb/core/api/vendors.py b/shopdb/core/api/vendors.py index 8a9c1e4..c4cb426 100644 --- a/shopdb/core/api/vendors.py +++ b/shopdb/core/api/vendors.py @@ -44,7 +44,7 @@ def list_vendors(): @jwt_required(optional=True) def get_vendor(vendor_id: int): """Get a single vendor.""" - v = Vendor.query.get(vendor_id) + v = db.session.get(Vendor, vendor_id) if not v: return error_response( @@ -93,7 +93,7 @@ def create_vendor(): @require_role('admin') def update_vendor(vendor_id: int): """Update a vendor.""" - v = Vendor.query.get(vendor_id) + v = db.session.get(Vendor, vendor_id) if not v: return error_response( @@ -127,7 +127,7 @@ def update_vendor(vendor_id: int): @require_role('admin') def delete_vendor(vendor_id: int): """Delete (deactivate) a vendor.""" - v = Vendor.query.get(vendor_id) + v = db.session.get(Vendor, vendor_id) if not v: return error_response( diff --git a/shopdb/core/models/auditlog.py b/shopdb/core/models/auditlog.py index c18e056..7e7896c 100644 --- a/shopdb/core/models/auditlog.py +++ b/shopdb/core/models/auditlog.py @@ -1,8 +1,12 @@ """Audit log model for tracking changes.""" -from datetime import datetime +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): """ @@ -19,7 +23,7 @@ class AuditLog(db.Model): username = db.Column(db.String(100), nullable=True) # Denormalized for history # When - timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + 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 diff --git a/shopdb/core/models/base.py b/shopdb/core/models/base.py index f91176c..9349418 100644 --- a/shopdb/core/models/base.py +++ b/shopdb/core/models/base.py @@ -1,9 +1,14 @@ """Base model class with common fields.""" -from datetime import datetime +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. @@ -13,13 +18,13 @@ class BaseModel(db.Model): createddate = db.Column( db.DateTime, - default=datetime.utcnow, + default=_utcnow, nullable=False ) modifieddate = db.Column( db.DateTime, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=_utcnow, + onupdate=_utcnow, nullable=False ) isactive = db.Column(db.Boolean, default=True, nullable=False) @@ -55,7 +60,7 @@ class SoftDeleteMixin: def soft_delete(self, deleted_by: str = None): """Mark record as deleted.""" self.isactive = False - self.deleteddate = datetime.utcnow() + self.deleteddate = datetime.now(timezone.utc).replace(tzinfo=None) self.deletedby = deleted_by diff --git a/shopdb/core/models/setting.py b/shopdb/core/models/setting.py index b7f896b..8c82c1f 100644 --- a/shopdb/core/models/setting.py +++ b/shopdb/core/models/setting.py @@ -1,9 +1,17 @@ """System settings model for key-value configuration storage.""" -from datetime import datetime +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. @@ -19,8 +27,8 @@ class Setting(db.Model): 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=datetime.utcnow) - modifieddate = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + createddate = db.Column(db.DateTime, default=_utcnow) + modifieddate = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow) def to_dict(self): return { @@ -55,19 +63,40 @@ class Setting(db.Model): 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.""" + """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 not setting: - setting = cls(key=key, valuetype=valuetype, category=category, description=description) - db.session.add(setting) + if setting: + setting.value = cls._stringify(value) + db.session.commit() + return setting - # 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 - - 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/user.py b/shopdb/core/models/user.py index eb7828a..2b347fb 100644 --- a/shopdb/core/models/user.py +++ b/shopdb/core/models/user.py @@ -1,6 +1,6 @@ """User and authentication models.""" -from datetime import datetime +from datetime import datetime, timezone from shopdb.extensions import db from .base import BaseModel @@ -179,7 +179,7 @@ class User(BaseModel): def islocked(self): """Check if account is locked.""" if self.lockeduntil: - return datetime.utcnow() < self.lockeduntil + return datetime.now(timezone.utc).replace(tzinfo=None) < self.lockeduntil return False def hasrole(self, rolename: str) -> bool: diff --git a/shopdb/plugins/registry.py b/shopdb/plugins/registry.py index ed0a936..428b0e5 100644 --- a/shopdb/plugins/registry.py +++ b/shopdb/plugins/registry.py @@ -4,7 +4,7 @@ import json from pathlib import Path from typing import Dict, List, Optional from dataclasses import dataclass, field, asdict -from datetime import datetime +from datetime import datetime, timezone @dataclass @@ -59,7 +59,7 @@ class PluginRegistry: state = PluginState( name=name, version=version, - installed_at=datetime.utcnow().isoformat(), + installed_at=datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), enabled=enabled ) self._plugins[name] = state diff --git a/shopdb/static/images/floorplan-placeholder.svg b/shopdb/static/images/floorplan-placeholder.svg new file mode 100644 index 0000000..e33d54a --- /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/responses.py b/shopdb/utils/responses.py index fd3dcf6..7ca7960 100644 --- a/shopdb/utils/responses.py +++ b/shopdb/utils/responses.py @@ -2,7 +2,7 @@ from flask import jsonify, make_response from typing import Any, Dict, List, Optional -from datetime import datetime +from datetime import datetime, timezone import uuid @@ -43,7 +43,7 @@ def api_response( response = { 'status': status, 'meta': { - 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z', 'requestid': str(uuid.uuid4())[:8], **(meta or {}) } diff --git a/sql/widen_notification_employee_columns.sql b/sql/widen_notification_employee_columns.sql deleted file mode 100644 index 4c511fc..0000000 --- a/sql/widen_notification_employee_columns.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Widen notifications.employeesso / employeename to TEXT. --- --- Recognition and recertification notifications comma-join every listed --- employee's SSO and name into one column. VARCHAR(100) truncated the list at --- ~11 people, dropping names off the shopfloor grid. TEXT removes the cap. --- Idempotent - re-running MODIFY to the same type is a no-op. - -ALTER TABLE notifications - MODIFY employeesso TEXT, - MODIFY employeename TEXT; diff --git a/tests/test_core/test_auth_ratelimit.py b/tests/test_core/test_auth_ratelimit.py new file mode 100644 index 0000000..84ab1e7 --- /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 index b5e1baf..107c04b 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -56,6 +56,49 @@ def test_member_cannot_create_business_unit(client, db, 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): diff --git a/tests/test_core/test_collector_contract.py b/tests/test_core/test_collector_contract.py index 054bd31..2a46e09 100644 --- a/tests/test_core/test_collector_contract.py +++ b/tests/test_core/test_collector_contract.py @@ -165,3 +165,46 @@ def test_placeholder_machinenumber_9999_falls_back_to_hostname(client, db, 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 diff --git a/tests/test_core/test_dashboarddefaults.py b/tests/test_core/test_dashboarddefaults.py index 53aa7fd..34261d4 100644 --- a/tests/test_core/test_dashboarddefaults.py +++ b/tests/test_core/test_dashboarddefaults.py @@ -42,3 +42,58 @@ def test_duplicate_ip_rejected(client, db, auth_headers, businessunit): 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_search_integrations.py b/tests/test_core/test_search_integrations.py new file mode 100644 index 0000000..4da87cc --- /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_setting_model.py b/tests/test_core/test_setting_model.py new file mode 100644 index 0000000..4c53e3e --- /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 0000000..44691fd --- /dev/null +++ b/tests/test_core/test_settings_branding.py @@ -0,0 +1,166 @@ +"""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_masks_secrets(client, db): + """Anonymous GET /api/settings never returns password/token/secret values.""" + 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 non-secret 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']} + + assert by_key['smtp_password'] == '********' + assert by_key['zabbix_token'] == '********' + assert by_key['warranty_dell_clientsecret'] == '********' + # 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) + # Non-secret value 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' + + +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 index ff7aeb0..1a6a04b 100644 --- a/tests/test_core/test_slides.py +++ b/tests/test_core/test_slides.py @@ -1,14 +1,25 @@ -"""Characterization test for the slides (TV slideshow) endpoint. +"""Characterization test for the slides (TV slideshow) plugin feed. -Written before extracting slides into a plugin: /api/slides must behave -identically as a plugin blueprint (same prefix, same response shape). +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_endpoint_shape(client, db): - """GET /api/slides returns a slides list and a basepath (no auth required).""" - resp = client.get('/api/slides') +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()['data'] + data = resp.get_json() + assert data['success'] is True + assert data['surface'] == 'lobby' assert isinstance(data['slides'], list) - assert 'basepath' in data + 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_plugins/test_shopfloor_feed.py b/tests/test_plugins/test_shopfloor_feed.py index ab9e302..21d8b4a 100644 --- a/tests/test_plugins/test_shopfloor_feed.py +++ b/tests/test_plugins/test_shopfloor_feed.py @@ -1,8 +1,9 @@ """Tests for the shopfloor TV feed (/api/notifications/shopfloor). -Recognition AND training notifications that name several comma-joined SSOs fan -out into one card per employee; every other type stays a single card. Mirrors -classic apishopfloor.asp, which splits both types. +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 @@ -10,8 +11,9 @@ import pytest from plugins.notifications.models import Notification, NotificationType -def _make_type(db, typename, typecolor): - t = NotificationType(typename=typename, typecolor=typecolor, isactive=True) +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 @@ -34,8 +36,8 @@ def _make_shopfloor_note(db, ntype, employeesso, employeename): @pytest.mark.parametrize('typecolor', ['recognition', 'training']) def test_multi_employee_split_into_one_card_each(client, db, typecolor): - """A recognition/training note with two SSOs yields two current cards.""" - ntype = _make_type(db, typecolor.capitalize(), 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') @@ -47,7 +49,7 @@ def test_multi_employee_split_into_one_card_each(client, db, typecolor): def test_non_split_type_stays_single_card(client, db): - """A non recognition/training type is not fanned out, even with many SSOs.""" + """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') @@ -60,7 +62,7 @@ def test_non_split_type_stays_single_card(client, db): 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') + ntype = _make_type(db, 'Recognition', 'recognition', splitperemployee=True) _make_shopfloor_note(db, ntype, '111', 'Alice') resp = client.get('/api/notifications/shopfloor') diff --git a/tools/shot.py b/tools/shot.py index 0766fd3..d7f70a6 100644 --- a/tools/shot.py +++ b/tools/shot.py @@ -8,6 +8,7 @@ the auth store does, then screenshots each path passed on the command line. Images land in the scratchpad dir as shot_.png. """ +import os import sys import json import urllib.request @@ -16,8 +17,9 @@ from playwright.sync_api import sync_playwright UI = "http://localhost:5173" API = "http://localhost:5001/api" -USERNAME = "270015376" -PASSWORD = "changeme" +# creds from env so no real login lands in source; fall back to dev defaults +USERNAME = os.environ.get("SHOT_USERNAME", "270015376") +PASSWORD = os.environ.get("SHOT_PASSWORD", "changeme") OUTDIR = "/tmp/claude-1000/-home-camp-projects/effc3424-ed5e-4b09-b83e-d141bee23c42/scratchpad" diff --git a/tools/shot_map_tab.py b/tools/shot_map_tab.py index cd1f22f..4f281f0 100644 --- a/tools/shot_map_tab.py +++ b/tools/shot_map_tab.py @@ -4,6 +4,7 @@ Logs in via the API (same as tools/shot.py), seeds the token into localStorage, opens /settings/system, clicks the Floor Map tab, and shots it. """ +import os import json import urllib.request @@ -11,8 +12,9 @@ from playwright.sync_api import sync_playwright UI = "http://localhost:5173" API = "http://localhost:5001/api" -USERNAME = "270015376" -PASSWORD = "changeme" +# creds from env so no real login lands in source; fall back to dev defaults +USERNAME = os.environ.get("SHOT_USERNAME", "270015376") +PASSWORD = os.environ.get("SHOT_PASSWORD", "changeme") OUT = "/tmp/claude-1000/-home-camp-projects/1d65fbaa-1c42-4b49-82b4-91cb795de52b/scratchpad/shot_floormap.png"