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 <noreply@anthropic.com>
This commit is contained in:
57
.gitea/workflows/ci.yml
Normal file
57
.gitea/workflows/ci.yml
Normal file
@@ -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
|
||||
73
CHANGELOG.md
Normal file
73
CHANGELOG.md
Normal file
@@ -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
|
||||
21
CLAUDE.md
21
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.
|
||||
|
||||
26
Dockerfile
26
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 <name>`.
|
||||
# bundles all ten core plugins (computers, employees, equipment,
|
||||
# knowledgebase, network, notifications, printers, slides, usb, warranty);
|
||||
# install them at runtime with `flask plugin install <name>`.
|
||||
#
|
||||
# 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 <repo>/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
|
||||
|
||||
101
README.md
101
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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
123
docs/BACKUP-RESTORE.md
Normal file
123
docs/BACKUP-RESTORE.md
Normal file
@@ -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 <host> -u <user> -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 <host> -u <user> -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
|
||||
@@ -11,6 +11,12 @@ Auth: API key header `X-API-Key: <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=<key>` querystring fallback has been removed, on every collector
|
||||
> endpoint (`/api/collector/<plugin>`, `/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 |
|
||||
|
||||
242
docs/CONFIG.md
Normal file
242
docs/CONFIG.md
Normal file
@@ -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://<user>:<pass>@<host>:<port>/<db>?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_<PLUGIN>` | 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_<name>_<assettype>_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_<type>_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
|
||||
@@ -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 `<repo>/plugins/<name>/` (the docker-compose mounts this read-only into the container) and run `flask plugin install <name>`.
|
||||
|
||||
## 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
|
||||
|
||||
194
docs/INSTALL-WINDOWS-IIS.md
Normal file
194
docs/INSTALL-WINDOWS-IIS.md
Normal file
@@ -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=<another 64+ random chars>
|
||||
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@<mysql-host>:3306/shopdb_flask?charset=utf8mb4
|
||||
CORS_ORIGINS=http://<the site's own hostname-or-ip:port>
|
||||
```
|
||||
|
||||
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
|
||||
> `<rewrite>` 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":"<the printed password>"}' `
|
||||
-ContentType application/json -UseBasicParsing # 200 + token
|
||||
```
|
||||
|
||||
Browse to `http://<host>: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 `<rewrite>` 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. |
|
||||
@@ -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).
|
||||
|
||||
112
docs/UPGRADE.md
Normal file
112
docs/UPGRADE.md
Normal file
@@ -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
|
||||
115
docs/adr/ADR-007-product-versioning-and-releases.md
Normal file
115
docs/adr/ADR-007-product-versioning-and-releases.md
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ShopDB</title>
|
||||
<link rel="icon" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/src/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shopdb-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="#4181ff"/>
|
||||
<text x="32" y="33" fill="#ffffff" font-family="Arial, Helvetica, sans-serif" font-size="40" font-weight="700" text-anchor="middle" dominant-baseline="central">S</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
1
frontend/public/ge-monogram.svg
Normal file
1
frontend/public/ge-monogram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
11
frontend/public/static/images/floorplan-placeholder.svg
Normal file
11
frontend/public/static/images/floorplan-placeholder.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="3300" height="2550" viewBox="0 0 3300 2550">
|
||||
<defs>
|
||||
<pattern id="grid" width="150" height="150" patternUnits="userSpaceOnUse">
|
||||
<path d="M 150 0 L 0 0 0 150" fill="none" stroke="#d0d4d9" stroke-width="2"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="3300" height="2550" fill="#f4f6f8"/>
|
||||
<rect width="3300" height="2550" fill="url(#grid)"/>
|
||||
<rect x="20" y="20" width="3260" height="2510" fill="none" stroke="#b8bec6" stroke-width="4"/>
|
||||
<text x="1650" y="1275" fill="#8a9099" font-family="Arial, Helvetica, sans-serif" font-size="90" font-weight="600" text-anchor="middle" dominant-baseline="central">Upload your facility floor plan in Settings > Map</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 726 B |
@@ -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' } })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
26
frontend/src/utils/qrTarget.js
Normal file
26
frontend/src/utils/qrTarget.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Shared QR target resolution for printed labels. Each label surface has a
|
||||
// qr_target_<domain> 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_<domain>
|
||||
// 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="sidebar-logo" />
|
||||
<h1>West Jefferson</h1>
|
||||
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
|
||||
<h1>{{ facilityName }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-search">
|
||||
@@ -63,12 +63,15 @@
|
||||
<span class="notification-text">{{ n.notification }}</span>
|
||||
<span class="notification-meta">
|
||||
<span v-if="n.starttime" class="notification-date">{{ formatDate(n.starttime) }}</span>
|
||||
<a
|
||||
v-if="n.ticketnumber"
|
||||
:href="`https://geit.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/${n.ticketnumber}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui`"
|
||||
target="_blank"
|
||||
class="notification-ticket"
|
||||
>{{ n.ticketnumber }}</a>
|
||||
<template v-if="n.ticketnumber">
|
||||
<a
|
||||
v-if="getTicketSearchUrl(n.ticketnumber)"
|
||||
:href="getTicketSearchUrl(n.ticketnumber)"
|
||||
target="_blank"
|
||||
class="notification-ticket"
|
||||
>{{ n.ticketnumber }}</a>
|
||||
<span v-else class="notification-ticket">{{ n.ticketnumber }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="login-logo" />
|
||||
<img :src="siteLogo" alt="Site logo" class="login-logo" />
|
||||
<h1>ShopDB</h1>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
@@ -51,10 +51,12 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { setupApi } from '../api'
|
||||
import { getSiteLogo } from '../utils/siteSettings'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const siteLogo = ref('/ge-aerospace-logo.svg')
|
||||
const mode = ref('login')
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
@@ -63,6 +65,8 @@ const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
getSiteLogo().then(logo => { siteLogo.value = logo })
|
||||
|
||||
// If the instance has no users yet, offer to create the first admin.
|
||||
try {
|
||||
const response = await setupApi.needsAdmin()
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
<label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label>
|
||||
<input v-model="form.site_base_url" type="url" class="form-control" placeholder="https://shopdb.example.net" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Site logo <span class="hint">(optional)</span></label>
|
||||
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
|
||||
<img v-if="siteLogo" :src="siteLogo" class="wizard-map-thumb" alt="site logo" />
|
||||
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings > Branding.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugins -->
|
||||
@@ -187,6 +193,24 @@ const seedResult = ref('')
|
||||
const mapUploading = ref(false)
|
||||
const blueprintLight = ref('')
|
||||
const blueprintDark = ref('')
|
||||
const logoUploading = ref(false)
|
||||
const siteLogo = ref('')
|
||||
|
||||
async function uploadSiteLogo(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
logoUploading.value = true
|
||||
try {
|
||||
const { data } = await settingsApi.uploadBrandingLogo('site', file)
|
||||
siteLogo.value = data?.data?.value || ''
|
||||
toast.success('Logo uploaded')
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Upload failed'))
|
||||
} finally {
|
||||
logoUploading.value = false
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBlueprint(theme, event) {
|
||||
const file = event.target.files[0]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="shopfloor-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<div class="logo-container">
|
||||
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="logo" />
|
||||
<img :src="siteLogo" alt="Site logo" class="logo" />
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
@@ -54,8 +54,8 @@
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/ge-aerospace-logo.svg"
|
||||
alt="GE Aerospace"
|
||||
:src="siteLogo"
|
||||
alt="Site logo"
|
||||
class="recognition-photo ge-logo-fallback"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,8 +98,8 @@
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/ge-aerospace-logo.svg"
|
||||
alt="GE Aerospace"
|
||||
:src="siteLogo"
|
||||
alt="Site logo"
|
||||
class="recert-photo ge-logo-fallback"
|
||||
/>
|
||||
<div class="recert-name">{{ rec.employeename || rec.employeesso }}</div>
|
||||
@@ -132,9 +132,12 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<a v-if="n.ticketnumber" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
|
||||
{{ n.ticketnumber }}
|
||||
</a>
|
||||
<template v-if="n.ticketnumber">
|
||||
<a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
|
||||
{{ n.ticketnumber }}
|
||||
</a>
|
||||
<span v-else class="event-ticket">{{ n.ticketnumber }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -155,9 +158,12 @@
|
||||
<strong>{{ formatDateTime(n.starttime) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<a v-if="n.ticketnumber" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
|
||||
{{ n.ticketnumber }}
|
||||
</a>
|
||||
<template v-if="n.ticketnumber">
|
||||
<a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
|
||||
{{ n.ticketnumber }}
|
||||
</a>
|
||||
<span v-else class="event-ticket">{{ n.ticketnumber }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -179,10 +185,13 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api'
|
||||
import { getFacilityName } from '@/utils/siteSettings'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
|
||||
|
||||
const loading = ref(true)
|
||||
const facilityName = ref('West Jefferson')
|
||||
const facilityName = ref('ShopDB')
|
||||
const siteLogo = ref('/ge-aerospace-logo.svg')
|
||||
// ServiceNow ticket-link config; loaded on mount. Empty/disabled = plain text.
|
||||
const servicenowConfig = ref({ enabled: true, incidentUrl: '', changeUrl: '' })
|
||||
const businessUnit = ref('')
|
||||
const businessUnits = ref([])
|
||||
const notifications = ref({ current: [], upcoming: [] })
|
||||
@@ -266,6 +275,8 @@ onMounted(async () => {
|
||||
setInterval(updateClock, 1000)
|
||||
|
||||
getFacilityName().then(name => { facilityName.value = name })
|
||||
getSiteLogo().then(logo => { siteLogo.value = logo })
|
||||
getServicenowUrls().then(config => { servicenowConfig.value = config })
|
||||
|
||||
// Load business units
|
||||
try {
|
||||
@@ -383,19 +394,22 @@ function formatDateTime(dateStr) {
|
||||
}
|
||||
|
||||
function getTicketUrl(ticketnumber) {
|
||||
if (!ticketnumber) return '#'
|
||||
// ServiceNow ticket URLs
|
||||
if (ticketnumber.startsWith('GEINC')) {
|
||||
return `https://ge.service-now.com/nav_to.do?uri=incident.do?sysparm_query=number=${ticketnumber}`
|
||||
// Return a URL only when ServiceNow is enabled and a matching template is set.
|
||||
// Null means "render the ticket number as plain text, no link".
|
||||
if (!ticketnumber) return null
|
||||
const config = servicenowConfig.value
|
||||
if (!config.enabled) return null
|
||||
if (ticketnumber.startsWith('GEINC') && config.incidentUrl) {
|
||||
return config.incidentUrl.replace('{ticket}', ticketnumber)
|
||||
}
|
||||
if (ticketnumber.startsWith('GECHG')) {
|
||||
return `https://ge.service-now.com/nav_to.do?uri=change_request.do?sysparm_query=number=${ticketnumber}`
|
||||
if (ticketnumber.startsWith('GECHG') && config.changeUrl) {
|
||||
return config.changeUrl.replace('{ticket}', ticketnumber)
|
||||
}
|
||||
return '#'
|
||||
return null
|
||||
}
|
||||
|
||||
function handlePhotoError(e) {
|
||||
e.target.src = '/ge-aerospace-logo.svg'
|
||||
e.target.src = siteLogo.value
|
||||
e.target.classList.add('ge-logo-fallback')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,374 +1,425 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Batch Print USB Barcode Labels</h3>
|
||||
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
|
||||
|
||||
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
|
||||
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
|
||||
<div v-else class="usb-grid">
|
||||
<div
|
||||
v-for="device in devices"
|
||||
:key="device.machineid"
|
||||
class="usb-item"
|
||||
:class="{ selected: isSelected(device) }"
|
||||
@click="toggleDevice(device)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(device)" @click.stop />
|
||||
<label>
|
||||
<strong><code>{{ device.serialnumber || '-' }}</code></strong>
|
||||
<div class="alias">{{ device.alias || device.machinenumber || '' }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
|
||||
(<span class="pages">{{ pageCount }}</span> pages)
|
||||
<label style="margin-left: 20px;">Start at cell:
|
||||
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
|
||||
<option value="1">1 - Top Left</option>
|
||||
<option value="2">2 - Top Right</option>
|
||||
<option value="3">3 - Middle Left</option>
|
||||
<option value="4">4 - Middle Right</option>
|
||||
<option value="5">5 - Bottom Left</option>
|
||||
<option value="6">6 - Bottom Right</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</button>
|
||||
<button class="clear-btn" @click="clearSelection">Clear All</button>
|
||||
<button class="select-all-btn" @click="selectAll">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheets-container">
|
||||
<div v-for="(page, pageIdx) in sheetPages" :key="pageIdx" class="print-sheet">
|
||||
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label-cell"
|
||||
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
|
||||
>
|
||||
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
|
||||
<div
|
||||
v-for="(miniItem, miniIdx) in page[cellNum - 1].items"
|
||||
:key="miniIdx"
|
||||
class="mini-label"
|
||||
:class="miniItem ? 'filled' : 'empty'"
|
||||
>
|
||||
<template v-if="miniItem">
|
||||
<div class="barcode-container">
|
||||
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
|
||||
</div>
|
||||
<div class="serial-text">{{ miniItem.serialnumber }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-cell-text">Empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { usbApi } from '../../api'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
|
||||
const MINI_LABELS_PER_CELL = 12
|
||||
const CELLS_PER_PAGE = 6
|
||||
|
||||
const devices = ref([])
|
||||
const selectedDevices = ref([])
|
||||
const loadingDevices = ref(true)
|
||||
const startCell = ref('1')
|
||||
const barcodeRefs = ref({})
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (selectedDevices.value.length === 0) return 0
|
||||
const skippedCells = parseInt(startCell.value) - 1
|
||||
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
|
||||
const totalCellsNeeded = numCells + skippedCells
|
||||
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
|
||||
})
|
||||
|
||||
const sheetPages = computed(() => {
|
||||
const result = []
|
||||
if (selectedDevices.value.length === 0) return result
|
||||
|
||||
const startCellNum = parseInt(startCell.value)
|
||||
let usbIdx = 0
|
||||
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
|
||||
const skipThisCell = page === 0 && cellNum < startCellNum
|
||||
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
|
||||
|
||||
const items = []
|
||||
if (hasContent) {
|
||||
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
|
||||
if (usbIdx < selectedDevices.value.length) {
|
||||
items.push(selectedDevices.value[usbIdx])
|
||||
usbIdx++
|
||||
} else {
|
||||
items.push(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells.push({ hasContent, items })
|
||||
}
|
||||
result.push(cells)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await usbApi.list({ perpage: 500 })
|
||||
devices.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading USB devices:', error)
|
||||
} finally {
|
||||
loadingDevices.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch([selectedDevices, startCell], async () => {
|
||||
await nextTick()
|
||||
generateBarcodes()
|
||||
}, { deep: true })
|
||||
|
||||
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
|
||||
if (el) {
|
||||
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
|
||||
}
|
||||
}
|
||||
|
||||
function generateBarcodes() {
|
||||
sheetPages.value.forEach((page, pageIdx) => {
|
||||
page.forEach((cell, cellIdx) => {
|
||||
if (!cell.hasContent) return
|
||||
const cellNum = cellIdx + 1
|
||||
cell.items.forEach((item, miniIdx) => {
|
||||
if (!item) return
|
||||
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
|
||||
if (!el) return
|
||||
try {
|
||||
JsBarcode(el, item.serialnumber, {
|
||||
format: 'CODE128',
|
||||
width: 1,
|
||||
height: 22,
|
||||
displayValue: false,
|
||||
margin: 0,
|
||||
background: 'transparent'
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Barcode error:', item.serialnumber, e)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isSelected(device) {
|
||||
return selectedDevices.value.some(d => d.machineid === device.machineid)
|
||||
}
|
||||
|
||||
function toggleDevice(device) {
|
||||
const idx = selectedDevices.value.findIndex(d => d.machineid === device.machineid)
|
||||
if (idx > -1) {
|
||||
selectedDevices.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedDevices.value.push(device)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedDevices.value = []
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedDevices.value = [...devices.value]
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.no-print { margin-bottom: 20px; padding: 20px; }
|
||||
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
|
||||
.controls h3 { margin-top: 0; }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
||||
|
||||
.clear-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.select-all-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.usb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.usb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.usb-item:hover { border-color: var(--primary); }
|
||||
.usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.usb-item input { margin-right: 10px; }
|
||||
.usb-item label { cursor: pointer; flex: 1; }
|
||||
.usb-item .alias { font-size: 11px; color: var(--text-light); }
|
||||
|
||||
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.selected-count .pages { color: var(--success); }
|
||||
|
||||
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
|
||||
|
||||
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
page-break-after: always;
|
||||
}
|
||||
.print-sheet:last-child { page-break-after: auto; }
|
||||
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
|
||||
.label-cell {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #ccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
.label-cell.has-content { border: 1px solid var(--primary); }
|
||||
.label-cell.empty { background: #fafafa; }
|
||||
|
||||
.cell-1 { top: 0.875in; left: 1.1875in; }
|
||||
.cell-2 { top: 0.875in; left: 4.3125in; }
|
||||
.cell-3 { top: 4in; left: 1.1875in; }
|
||||
.cell-4 { top: 4in; left: 4.3125in; }
|
||||
.cell-5 { top: 7.125in; left: 1.1875in; }
|
||||
.cell-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
.mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1in);
|
||||
grid-template-rows: repeat(4, 0.75in);
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
width: 1in;
|
||||
height: 0.75in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0.02in;
|
||||
border: 1px dotted #ddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-label.filled { border: 1px solid #999; }
|
||||
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
|
||||
|
||||
.barcode-container { text-align: center; line-height: 0; }
|
||||
.barcode-container svg { max-width: 0.9in; height: 24px; }
|
||||
|
||||
.serial-text {
|
||||
font-size: 6pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
margin-top: 1px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.empty-cell-text {
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media print {
|
||||
/* Force the barcodes/borders to print even when "Background graphics" is
|
||||
off. */
|
||||
body, .print-sheet, .label-cell, .mini-label, .barcode-container {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
body { padding: 0; margin: 0; background: white; }
|
||||
.no-print { display: none !important; }
|
||||
.sheets-container { gap: 0; }
|
||||
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
||||
.sheet-label { display: none; }
|
||||
.label-cell { border: none !important; }
|
||||
.label-cell.empty { visibility: hidden; }
|
||||
.mini-label { border: 1px dotted #ccc !important; }
|
||||
.mini-label.empty { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div>
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Batch Print USB Barcode Labels</h3>
|
||||
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
|
||||
|
||||
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
|
||||
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
|
||||
<div v-else class="usb-grid">
|
||||
<div
|
||||
v-for="device in devices"
|
||||
:key="device.device_id"
|
||||
class="usb-item"
|
||||
:class="{ selected: isSelected(device) }"
|
||||
@click="toggleDevice(device)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(device)" @click.stop />
|
||||
<label>
|
||||
<strong><code>{{ device.device_id || '-' }}</code></strong>
|
||||
<div class="alias">{{ device.device_desc || '' }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
|
||||
(<span class="pages">{{ pageCount }}</span> pages)
|
||||
<label style="margin-left: 20px;">Start at cell:
|
||||
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
|
||||
<option value="1">1 - Top Left</option>
|
||||
<option value="2">2 - Top Right</option>
|
||||
<option value="3">3 - Middle Left</option>
|
||||
<option value="4">4 - Middle Right</option>
|
||||
<option value="5">5 - Bottom Left</option>
|
||||
<option value="6">6 - Bottom Right</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</button>
|
||||
<button class="clear-btn" @click="clearSelection">Clear All</button>
|
||||
<button class="select-all-btn" @click="selectAll">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheets-container">
|
||||
<div v-for="(page, pageIdx) in sheetPages" :key="pageIdx" class="print-sheet">
|
||||
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label-cell"
|
||||
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
|
||||
>
|
||||
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
|
||||
<div
|
||||
v-for="(miniItem, miniIdx) in page[cellNum - 1].items"
|
||||
:key="miniIdx"
|
||||
class="mini-label"
|
||||
:class="miniItem ? 'filled' : 'empty'"
|
||||
>
|
||||
<template v-if="miniItem">
|
||||
<img
|
||||
v-if="labelStyle === 'qr'"
|
||||
class="qr-img"
|
||||
:src="qrImages[`${pageIdx}-${cellNum}-${miniIdx}`] || ''"
|
||||
/>
|
||||
<div v-else class="barcode-container">
|
||||
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
|
||||
</div>
|
||||
<div class="serial-text">{{ miniItem.device_id }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-cell-text">Empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { usbApi } from '../../api'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import QRCode from 'qrcode'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import { buildQrUrl } from '@/utils/qrTarget'
|
||||
|
||||
const MINI_LABELS_PER_CELL = 12
|
||||
const CELLS_PER_PAGE = 6
|
||||
|
||||
const devices = ref([])
|
||||
const selectedDevices = ref([])
|
||||
const loadingDevices = ref(true)
|
||||
const startCell = ref('1')
|
||||
const barcodeRefs = ref({})
|
||||
// usb_label_style setting: 'barcode' (CODE128 of the serial) or 'qr' (QR code
|
||||
// linking to the qr_target_usb target, default = the device page).
|
||||
const labelStyle = ref('barcode')
|
||||
// QR data-URL images keyed like barcodeRefs. Images print reliably; live
|
||||
// canvases do not.
|
||||
const qrImages = ref({})
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (selectedDevices.value.length === 0) return 0
|
||||
const skippedCells = parseInt(startCell.value) - 1
|
||||
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
|
||||
const totalCellsNeeded = numCells + skippedCells
|
||||
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
|
||||
})
|
||||
|
||||
const sheetPages = computed(() => {
|
||||
const result = []
|
||||
if (selectedDevices.value.length === 0) return result
|
||||
|
||||
const startCellNum = parseInt(startCell.value)
|
||||
let usbIdx = 0
|
||||
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
|
||||
const skipThisCell = page === 0 && cellNum < startCellNum
|
||||
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
|
||||
|
||||
const items = []
|
||||
if (hasContent) {
|
||||
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
|
||||
if (usbIdx < selectedDevices.value.length) {
|
||||
items.push(selectedDevices.value[usbIdx])
|
||||
usbIdx++
|
||||
} else {
|
||||
items.push(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells.push({ hasContent, items })
|
||||
}
|
||||
result.push(cells)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
labelStyle.value = (await getSetting('usb_label_style', 'barcode')) === 'qr' ? 'qr' : 'barcode'
|
||||
try {
|
||||
const response = await usbApi.list({ perpage: 500 })
|
||||
devices.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading USB devices:', error)
|
||||
} finally {
|
||||
loadingDevices.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch([selectedDevices, startCell], async () => {
|
||||
await nextTick()
|
||||
if (labelStyle.value === 'qr') {
|
||||
await generateQrImages()
|
||||
} else {
|
||||
generateBarcodes()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
async function generateQrImages() {
|
||||
const next = {}
|
||||
for (let pageIdx = 0; pageIdx < sheetPages.value.length; pageIdx++) {
|
||||
const page = sheetPages.value[pageIdx]
|
||||
for (let cellIdx = 0; cellIdx < page.length; cellIdx++) {
|
||||
const cell = page[cellIdx]
|
||||
if (!cell.hasContent) continue
|
||||
for (let miniIdx = 0; miniIdx < cell.items.length; miniIdx++) {
|
||||
const item = cell.items[miniIdx]
|
||||
if (!item) continue
|
||||
try {
|
||||
const url = await buildQrUrl('qr_target_usb', `/usb/${encodeURIComponent(item.device_id)}`, {
|
||||
id: item.device_id || '',
|
||||
serialnumber: item.device_id || '',
|
||||
alias: item.device_desc || '',
|
||||
})
|
||||
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] = await QRCode.toDataURL(url, {
|
||||
margin: 0,
|
||||
width: 150,
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('QR error:', item.device_id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
qrImages.value = next
|
||||
}
|
||||
|
||||
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
|
||||
if (el) {
|
||||
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
|
||||
}
|
||||
}
|
||||
|
||||
function generateBarcodes() {
|
||||
sheetPages.value.forEach((page, pageIdx) => {
|
||||
page.forEach((cell, cellIdx) => {
|
||||
if (!cell.hasContent) return
|
||||
const cellNum = cellIdx + 1
|
||||
cell.items.forEach((item, miniIdx) => {
|
||||
if (!item) return
|
||||
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
|
||||
if (!el) return
|
||||
try {
|
||||
JsBarcode(el, item.device_id, {
|
||||
format: 'CODE128',
|
||||
width: 1,
|
||||
height: 22,
|
||||
displayValue: false,
|
||||
margin: 0,
|
||||
background: 'transparent'
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Barcode error:', item.device_id, e)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isSelected(device) {
|
||||
return selectedDevices.value.some(d => d.device_id === device.device_id)
|
||||
}
|
||||
|
||||
function toggleDevice(device) {
|
||||
const idx = selectedDevices.value.findIndex(d => d.device_id === device.device_id)
|
||||
if (idx > -1) {
|
||||
selectedDevices.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedDevices.value.push(device)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedDevices.value = []
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedDevices.value = [...devices.value]
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.no-print { margin-bottom: 20px; padding: 20px; }
|
||||
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
|
||||
.controls h3 { margin-top: 0; }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
||||
|
||||
.clear-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.select-all-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.usb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.usb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.usb-item:hover { border-color: var(--primary); }
|
||||
.usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.usb-item input { margin-right: 10px; }
|
||||
.usb-item label { cursor: pointer; flex: 1; }
|
||||
.usb-item .alias { font-size: 11px; color: var(--text-light); }
|
||||
|
||||
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.selected-count .pages { color: var(--success); }
|
||||
|
||||
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
|
||||
|
||||
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
page-break-after: always;
|
||||
}
|
||||
.print-sheet:last-child { page-break-after: auto; }
|
||||
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
|
||||
.label-cell {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #ccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
.label-cell.has-content { border: 1px solid var(--primary); }
|
||||
.label-cell.empty { background: #fafafa; }
|
||||
|
||||
.cell-1 { top: 0.875in; left: 1.1875in; }
|
||||
.cell-2 { top: 0.875in; left: 4.3125in; }
|
||||
.cell-3 { top: 4in; left: 1.1875in; }
|
||||
.cell-4 { top: 4in; left: 4.3125in; }
|
||||
.cell-5 { top: 7.125in; left: 1.1875in; }
|
||||
.cell-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
.mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1in);
|
||||
grid-template-rows: repeat(4, 0.75in);
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
width: 1in;
|
||||
height: 0.75in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0.02in;
|
||||
border: 1px dotted #ddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-label.filled { border: 1px solid #999; }
|
||||
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
|
||||
|
||||
.barcode-container { text-align: center; line-height: 0; }
|
||||
.barcode-container svg { max-width: 0.9in; height: 24px; }
|
||||
|
||||
.qr-img { width: 0.48in; height: 0.48in; }
|
||||
|
||||
.serial-text {
|
||||
font-size: 6pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
margin-top: 1px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.empty-cell-text {
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media print {
|
||||
/* Force the barcodes/borders to print even when "Background graphics" is
|
||||
off. */
|
||||
body, .print-sheet, .label-cell, .mini-label, .barcode-container {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
body { padding: 0; margin: 0; background: white; }
|
||||
.no-print { display: none !important; }
|
||||
.sheets-container { gap: 0; }
|
||||
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
||||
.sheet-label { display: none; }
|
||||
.label-cell { border: none !important; }
|
||||
.label-cell.empty { visibility: hidden; }
|
||||
.mini-label { border: 1px dotted #ccc !important; }
|
||||
.mini-label.empty { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
|
||||
|
||||
let logoImage = null
|
||||
|
||||
function loadLogo() {
|
||||
// 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
<div v-else class="form-grid">
|
||||
<label v-for="s in items" :key="s.key" class="field">
|
||||
<span>{{ prettyLabel(s.key) }}</span>
|
||||
<input v-model="s.value" type="text" :placeholder="s.description" />
|
||||
<small class="muted">{{ s.description }}</small>
|
||||
<input v-model="s.value" type="text" :placeholder="fieldHelp(s.key) ? '' : s.description" />
|
||||
<small class="muted">{{ fieldHelp(s.key) || s.description }}</small>
|
||||
</label>
|
||||
|
||||
<div v-if="!items.length" class="muted">No site settings found.</div>
|
||||
@@ -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 {
|
||||
|
||||
@@ -157,6 +157,198 @@
|
||||
<span>{{ dellMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>ServiceNow</h3>
|
||||
<p class="setting-description">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable ServiceNow</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.servicenow_enabled }"
|
||||
@click="toggleSetting('servicenow_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.servicenow_enabled">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Search URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_search_url"
|
||||
placeholder="https://geaerospaceqa.service-now.com/now/nav/ui/search/.../{ticket}/..."
|
||||
@blur="saveSetting('servicenow_search_url', settings.servicenow_search_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Global-search redirect target. Use {ticket} where the ticket number goes.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Ticket Prefixes</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.servicenow_ticket_prefixes"
|
||||
placeholder="GEINC,GECHG,GERIT,GESCT"
|
||||
@blur="saveSetting('servicenow_ticket_prefixes', settings.servicenow_ticket_prefixes)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Comma-separated ticket-number prefixes that this site recognizes.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Incident URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_incident_url"
|
||||
placeholder="(blank = plain text; use {ticket} in a URL template)"
|
||||
@blur="saveSetting('servicenow_incident_url', settings.servicenow_incident_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Link template for incident tickets. Use {ticket} where the ticket number goes.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Change URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_change_url"
|
||||
placeholder="(blank = plain text; use {ticket} in a URL template)"
|
||||
@blur="saveSetting('servicenow_change_url', settings.servicenow_change_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Link template for change tickets. Use {ticket} where the ticket number goes.</small>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branding Section -->
|
||||
<div class="section-card" v-show="isVisible('branding')">
|
||||
<h2 class="section-title">Branding</h2>
|
||||
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="setting-row" v-for="logo in brandingLogos" :key="logo.kind">
|
||||
<label>
|
||||
<span>{{ logo.label }}</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings[logo.key]"
|
||||
:placeholder="logo.placeholder"
|
||||
@blur="saveSetting(logo.key, settings[logo.key])"
|
||||
:disabled="saving"
|
||||
>
|
||||
<div class="map-upload-row">
|
||||
<input type="file" :accept="logo.accept" @change="uploadLogo(logo.kind, logo.key, $event)" :disabled="brandingUploading" />
|
||||
<img v-if="settings[logo.key]" :src="settings[logo.key]" class="map-thumb" :alt="logo.label" />
|
||||
</div>
|
||||
<small class="input-hint">{{ logo.hint }}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Primary brand color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_primary_color || '#000000'"
|
||||
@input="settings.brand_primary_color = $event.target.value"
|
||||
@change="saveSetting('brand_primary_color', settings.brand_primary_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_primary_color"
|
||||
placeholder="(blank = built-in)"
|
||||
@blur="saveSetting('brand_primary_color', settings.brand_primary_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Hex color for the primary accent. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printing & Labels Section -->
|
||||
<div class="section-card" v-show="isVisible('printing')">
|
||||
<h2 class="section-title">Printing & Labels</h2>
|
||||
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Printer QR target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_printer"
|
||||
placeholder="(blank = printer page)"
|
||||
@blur="saveSetting('qr_target_printer', settings.qr_target_printer)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>USB label QR target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_usb"
|
||||
placeholder="(blank = USB device page)"
|
||||
@blur="saveSetting('qr_target_usb', settings.qr_target_usb)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {id}, {serialnumber}, {alias}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>USB label style</span>
|
||||
<select
|
||||
v-model="settings.usb_label_style"
|
||||
@change="saveSetting('usb_label_style', settings.usb_label_style)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="barcode">Barcode (CODE128 of the serial number)</option>
|
||||
<option value="qr">QR code (links to the USB label QR target)</option>
|
||||
</select>
|
||||
<small class="input-hint">Applies to the batch USB mini-label print sheet.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Section -->
|
||||
@@ -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_<name>_<assettype>_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; }
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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('/<int:notification_id>', 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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('/<int:warrantyid>', 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
16
scripts/migration/one-offs/README.md
Normal file
16
scripts/migration/one-offs/README.md
Normal file
@@ -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`.
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/<plugin>',
|
||||
'GET /api/collector/_schemas',
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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__
|
||||
})
|
||||
|
||||
@@ -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('/<int:default_id>', 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('/<int:default_id>', 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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('/<int:userid>', 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/<int:roleid>', 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/<int:roleid>', 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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
11
shopdb/static/images/floorplan-placeholder.svg
Normal file
11
shopdb/static/images/floorplan-placeholder.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="3300" height="2550" viewBox="0 0 3300 2550">
|
||||
<defs>
|
||||
<pattern id="grid" width="150" height="150" patternUnits="userSpaceOnUse">
|
||||
<path d="M 150 0 L 0 0 0 150" fill="none" stroke="#d0d4d9" stroke-width="2"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="3300" height="2550" fill="#f4f6f8"/>
|
||||
<rect width="3300" height="2550" fill="url(#grid)"/>
|
||||
<rect x="20" y="20" width="3260" height="2510" fill="none" stroke="#b8bec6" stroke-width="4"/>
|
||||
<text x="1650" y="1275" fill="#8a9099" font-family="Arial, Helvetica, sans-serif" font-size="90" font-weight="600" text-anchor="middle" dominant-baseline="central">Upload your facility floor plan in Settings > Map</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 726 B |
@@ -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 {})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
70
tests/test_core/test_auth_ratelimit.py
Normal file
70
tests/test_core/test_auth_ratelimit.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
105
tests/test_core/test_search_integrations.py
Normal file
105
tests/test_core/test_search_integrations.py
Normal file
@@ -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()
|
||||
56
tests/test_core/test_setting_model.py
Normal file
56
tests/test_core/test_setting_model.py
Normal file
@@ -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
|
||||
166
tests/test_core/test_settings_branding.py
Normal file
166
tests/test_core/test_settings_branding.py
Normal file
@@ -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'<svg/>'), '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'<svg/>'), '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'<svg xmlns="http://www.w3.org/2000/svg"></svg>'
|
||||
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'<svg/>'), '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']
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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_<sanitised-path>.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"
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user