From d6725c08e01dc3cd8e79350e2d1fdb303872deca Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 8 May 2026 14:47:30 -0400 Subject: [PATCH] Phase 0: lock platform contract, naming convention, and style enforcement Establishes the framework's foundation as a multi-site adoptable platform. ADRs (migrations/adr/): - ADR-001 (ACCEPTED): Asset is the platform contract; Machine retires. Three relationship types (partof, controls, connectedto) with free-text label, position-resolution chain (asset > related > location), hierarchical locations, sibling-bay propagation. - ADR-002 (ACCEPTED): Plugin contract semver via __contract_version__. - ADR-003 (ACCEPTED): Hybrid plugin distribution (in-tree bundled + filesystem-based external). - ADR-004 (ACCEPTED): Per-site instances, not multi-tenant. - ADR-005 (ACCEPTED): Equipment plugin (manufacturing) split from measuringtools plugin (metrology). Subtype-table pattern for protocol data (FOCAS, CLM, MTConnect). - ADR-006 (ACCEPTED): Plugin collector contract via get_collector_schema hook with API-key auth and identity-based upsert. Naming convention v1 (CONTRIBUTING.md): - DB tables/columns: lowercase concatenated, no underscores or dashes - DB-mirrored Python/JS variables match column names exactly; pure code follows host-language convention (PEP 8 / camelCase) - Closed acronym allowlist (universal + shop-floor domain), banned shorthand list with suffix exception (printers_bp etc allowed) - Plain ASCII everywhere: chat, docs, comments, string literals Style enforcement (scripts/check-naming-and-style.sh): - Pre-commit-runnable check script: non-ASCII, banned shorthand, snake_case DB names, snake_case API params in frontend - Fixes 14 violations across 11 files (Unicode arrows, snake_case params, ctx -> canvasContext, res -> response, req -> request_obj) Project state (CLAUDE.md, README.md, frontend/CLAUDE.md): - De-staled CLAUDE.md to reflect actual current state - README unifies DB story (MySQL canonical, SQLite test-only) - frontend/CLAUDE.md points at root convention Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 166 +++++++++------- CONTRIBUTING.md | 184 ++++++++++++------ README.md | 4 +- frontend/CLAUDE.md | 16 ++ .../src/components/AssetRelationships.vue | 4 +- frontend/src/components/EmployeeSearch.vue | 4 +- frontend/src/views/ShopfloorDashboard.vue | 8 +- .../src/views/network/NetworkDevicesList.vue | 6 +- .../views/notifications/NotificationForm.vue | 4 +- .../views/notifications/NotificationsList.vue | 2 +- frontend/src/views/print/PrinterQRBatch.vue | 12 +- frontend/src/views/print/PrinterQRSingle.vue | 12 +- .../adr/ADR-001-asset-as-platform-contract.md | 173 ++++++++++++++++ migrations/adr/ADR-002-plugin-versioning.md | 60 ++++++ migrations/adr/ADR-003-plugin-distribution.md | 68 +++++++ migrations/adr/ADR-004-deployment-topology.md | 69 +++++++ .../ADR-005-equipment-vs-measuringtools.md | 149 ++++++++++++++ migrations/adr/ADR-006-collector-contract.md | 131 +++++++++++++ migrations/adr/README.md | 25 +++ scripts/check-naming-and-style.sh | 118 +++++++++++ shopdb/core/models/auditlog.py | 8 +- shopdb/utils/pagination.py | 10 +- 22 files changed, 1058 insertions(+), 175 deletions(-) create mode 100644 migrations/adr/ADR-001-asset-as-platform-contract.md create mode 100644 migrations/adr/ADR-002-plugin-versioning.md create mode 100644 migrations/adr/ADR-003-plugin-distribution.md create mode 100644 migrations/adr/ADR-004-deployment-topology.md create mode 100644 migrations/adr/ADR-005-equipment-vs-measuringtools.md create mode 100644 migrations/adr/ADR-006-collector-contract.md create mode 100644 migrations/adr/README.md create mode 100755 scripts/check-naming-and-style.sh diff --git a/CLAUDE.md b/CLAUDE.md index b894a98..efe1c29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,108 +1,124 @@ # ShopDB Flask Project -## Database Configuration +Modern rewrite of the classic-ASP shopdb. Built as a framework so sister GE Aerospace sites can adopt it. Plugin system is the product. -- **Development database:** `shopdb_flask` (new schema with asset abstraction) -- **Legacy database:** `shopdb` (Classic ASP schema - used for data migration reference only) -- **Connection:** Configured in `.env` file +## Database -## Current Tasks +- **Active database:** `shopdb_flask` (MySQL, asset-based schema) +- **Legacy database:** `shopdb` (Classic ASP schema, used only for one-time data import via `scripts/import_from_mysql.py`) +- **Connection:** `.env` file. See `.env.example`. -### Data Migration from Legacy Database +Architecture decisions live in `migrations/adr/`. Read those before making schema or contract changes. -Migrate data from `shopdb` to `shopdb_flask` using the new asset-based schema. +- ADR-001: Asset model is the platform contract (Machine retires) - ACCEPTED +- ADR-002: Plugin contract versioning (semver) - ACCEPTED +- ADR-003: Plugin distribution model (in-tree bundled + filesystem-based external) - ACCEPTED +- ADR-004: Deployment topology (per-site instances, not multi-tenant) - ACCEPTED +- ADR-005: Equipment vs measuringtools plugin scope - ACCEPTED +- ADR-006: Plugin collector contract pattern - ACCEPTED -**Status:** Pending +## Coding convention -**Migration guide:** `migrations/DATA_MIGRATION_GUIDE.md` +`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. -**Tables to migrate:** -- [ ] Reference data (vendors, models, locations, business units, subnets) -- [ ] Equipment (machines with category='Equipment') -- [ ] Computers/PCs (machines with pctypeid IS NOT NULL) -- [ ] Network devices (machines with category='Network') -- [ ] Printers (separate printers table) -- [ ] Communications/IP addresses -- [ ] Notifications -- [ ] Machine relationships +## Current state (as of 2026-05-08) -### Plugin Architecture Verification +### Wired in -Verify all plugins follow plug-and-play architecture - can be added/removed without impacting core site or other plugins. +- App factory pattern, Flask 3 + SQLAlchemy + Flask-Migrate + JWT + Marshmallow + CORS + Caching +- 6 plugins: computers, equipment, network, notifications, printers, usb +- Plugin contract: `BasePlugin` ABC, `PluginMeta`, registry, dependency-aware loader +- JWT auth with refresh tokens, audit logging, system settings, user management +- Frontend: Vue 3 + Pinia + Vite, dynamic plugin routing, dark mode default +- Search across all plugins via `get_searchable_fields` hook +- Asset relationships (cross-plugin) -**Plugins to verify:** -- [ ] Equipment (`plugins/equipment/`) - frontend: `views/machines/` -- [ ] Computers (`plugins/computers/`) - frontend: `views/pcs/` -- [ ] Printers (`plugins/printers/`) - frontend: `views/printers/` -- [ ] Network (`plugins/network/`) - frontend: `views/network/` -- [ ] USB (`plugins/usb/`) - frontend: `views/usb/` -- [ ] Notifications (`plugins/notifications/`) - frontend: `views/notifications/` +### In progress / partial -**Architecture checks:** -- [ ] Core API uses try/except ImportError for plugin imports (see `shopdb/core/api/assets.py` for pattern) -- [ ] Frontend router can dynamically add/remove routes per plugin -- [ ] Navigation menu reads from plugin `get_navigation_items()` -- [ ] Document plugin enable/disable mechanism in app factory +- **Dual model coexistence**: legacy `Machine` and new `Asset` both live. ADR-001 settles direction (Asset wins). Migration plan is the next concrete deliverable. +- **Plugin verification**: 6 plugins follow `BasePlugin` interface but no contract test asserts compliance. Skill `enforcing-plugin-contract` and the contract test suite are pending. +- **Tests**: hollow. `tests/conftest.py` exists but uses Flask-SQLAlchemy 2.x patterns that error on 3.x. No actual test files. Skill `pinning-flask-behavior` covers the rebuild. -**Each plugin must have:** -- `models/__init__.py` - exports all models -- `api/routes.py` - Flask Blueprint with endpoints -- `plugin.py` - implements BasePlugin class -- `manifest.json` - plugin metadata (name, version, dependencies) -- No direct imports from core code (use optional imports) -- Modular frontend components +### Pending -## Plugin Structure +- Alembic versions directory exists (`migrations/versions/`) but is empty. No migrations have been generated yet. Run `flask db init` is partial; need `flask db migrate` to capture current schema. +- Plugin scaffold CLI (`flask plugin new `) +- Plugin author docs (`docs/PLUGIN-QUICKSTART.md`, `docs/PLUGIN-REFERENCE.md`) +- Per-site deploy story (`Dockerfile`, `docs/DEPLOY.md`) +- Frontend hook contract (asset-detail, map markers, search results) -``` -plugins/ - {plugin_name}/ - __init__.py - plugin.py # BasePlugin implementation - manifest.json # Plugin metadata - models/ - __init__.py # Export all models - {model}.py # SQLAlchemy models - api/ - __init__.py - routes.py # Flask Blueprint -``` - -## Key Files - -- `shopdb/plugins/base.py` - BasePlugin class and PluginMeta -- `shopdb/core/api/assets.py` - Example of optional plugin imports with try/except -- `frontend/src/router/index.js` - Frontend routing -- `frontend/src/components/AppSidebar.vue` - Navigation menu - -## Migration Notes - -See `migrations/` folder for: -- `DATA_MIGRATION_GUIDE.md` - Complete guide for migrating from legacy shopdb to new schema -- `MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md` - USB device migration from equipment table -- `FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - Fix for LocationOnly equipment types -- `PRODUCTION_MIGRATION_GUIDE.md` - Production data import methods - -## Quick Start +## Quick start ```bash # Start dev environment ~/start-dev-env.sh -# Create/update database tables +# Activate venv and install deps cd /home/camp/projects/shopdb-flask source venv/bin/activate +pip install -r requirements.txt + +# Configure environment +cp .env.example .env +# Edit .env with DB credentials, JWT secrets + +# Create / update database tables flask db-utils create-all # Seed reference data flask seed reference-data -# Restart services after changes +# Restart services pm2 restart shopdb-flask-api shopdb-flask-ui ``` ## Service URLs -- **Flask API:** http://localhost:5001 -- **Flask UI:** http://localhost:5173 -- **Legacy ASP:** http://192.168.122.151:8080 +- Flask API: http://localhost:5001 +- Flask UI: http://localhost:5173 +- Legacy ASP (data source for one-time import): http://192.168.122.151:8080 + +## Plugin structure + +``` +plugins/ + / + __init__.py + plugin.py # BasePlugin implementation + manifest.json # Plugin metadata (name, version, dependencies, api_prefix) + models/ + __init__.py # Export all models + .py # SQLAlchemy models + api/ + __init__.py + routes.py # Flask Blueprint + services/ # Optional, business logic + schemas/ # Optional, marshmallow schemas + migrations/ # Optional, plugin-specific Alembic migrations +``` + +Each plugin must have: + +- `models/__init__.py` exports all models +- `plugin.py` extends `BasePlugin` +- `manifest.json` with metadata (single source of truth per ADR-002) +- No direct imports from core code (use the contract surface defined in ADR-001) + +## Key files + +- `shopdb/__init__.py` - app factory, blueprint registration +- `shopdb/plugins/base.py` - BasePlugin ABC, PluginMeta dataclass +- `shopdb/plugins/loader.py` - filesystem discovery, dependency-aware loading +- `shopdb/core/api/assets.py` - example of optional plugin imports +- `frontend/src/router/index.js` - frontend routing +- `frontend/src/components/AppSidebar.vue` - navigation menu +- `migrations/adr/` - architecture decision records + +## Migration notes + +- `migrations/DATA_MIGRATION_GUIDE.md` - one-time import from legacy ASP shopdb +- `migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md` - USB device migration from equipment table +- `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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 525c07e..1dac0ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,93 +2,141 @@ ## Coding Standards -### Database Naming Convention +### Naming principles -**IMPORTANT: No underscores in database identifiers** +1. Spell it out. If a non-technical user looking at a report would not understand a column at a glance, rename it. +2. No project-specific shorthand. A name either uses a word in full, or uses an acronym from the allowed list below. +3. Consistency beats cleverness. Once a name is committed and used in code, do not rename it for marginal improvement. +4. When unsure, ask: "would a new shop-floor IT person understand this?" -All database table names and column names must use lowercase concatenated words (no underscores). +### Database -| Pattern | Good | Bad | -|---------|------|-----| -| Table names | `printerdata` | `printer_data` | -| Column names | `passwordhash` | `password_hash` | -| Foreign keys | `machinetypeid` | `machine_type_id` | -| Index names | `idx_printer_zabbix` | Allowed for indexes | +- **Table names**: lowercase, concatenated, plural. No dashes, no underscores. + - Good: `machines`, `pctypes`, `networkdevices`, `businessunits`, `auditlogs` + - Bad: `machine_types`, `network-devices`, `BusinessUnits` +- **Column names**: lowercase, concatenated, singular. No dashes, no underscores. + - Good: `machineid`, `machinenumber`, `lastzabbixsync`, `isactive` + - Bad: `machine_id`, `last_zabbix_sync`, `IsActive` +- **Foreign keys**: referenced table name (singular) + `id`. + - Good: `locationid` references `locations`, `vendorid` references `vendors` +- **Booleans**: prefix with `is` or `has`. + - Good: `isactive`, `isshopfloor`, `hasprinter` +- **Index names**: `idx__`. Underscores allowed here only because indexes are infrastructure, not data. + - Good: `idx_machines_locationid` -### Intuitive Naming for Non-Technical Users +### Allowed acronyms (closed list, expand by PR) -Database tables and columns should use **simple, intuitive names** that non-technical users can understand when viewing data or reports. +- **Universal**: `id`, `url`, `api`, `http`, `https`, `json`, `jwt`, `sql`, `os`, `ip`, `dns`, `csv`, `pdf`, `cors`, `ttl`, `uuid`, `html`, `css`, `orm` +- **Domain (shop floor / IT infra)**: `cmm`, `cnc`, `pc`, `usb`, `vnc`, `winrm`, `ssh`, `ssl`, `tls`, `tcp`, `udp`, `smtp`, `ldap`, `vlan`, `sso`, `dnc`, `focas`, `clm`, `mtconnect` -| Avoid | Prefer | Why | -|-------|--------|-----| -| `printerextensions` | `printerdata` | "data" is clearer than "extensions" | -| `machinerelationships` | `machinelinks` | "links" is simpler (consider) | -| `comtypeid` | `connectiontypeid` | Spell it out when unclear | +Any acronym not on these lists must be spelled out. Proposing a new acronym is a separate PR that updates this list. -**Guiding principle:** If someone unfamiliar with the system looked at a table name, would they understand what's in it? +**Third-party imports are exempt.** Library and stdlib module paths cannot be renamed. `from sqlalchemy.orm import ...`, `import importlib.util`, `from werkzeug.utils import ...` are all fine even if they contain words that would otherwise be banned. -Examples: +### Banned shorthand + +`cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`, and any other domain-specific shortening. Spell it out: `config`, `context`, `manager`, `request`, `response`, `environment`, `utilities`. Note: `db` as a standalone variable name is banned, but `db` as a prefix in `database` or as part of project name `shopdb` is fine. + +**Suffix exception.** Banned shorthand applies to standalone identifiers. As a suffix where the prefix establishes meaning, it is acceptable: + +- `printers_bp`, `auth_bp`, `network_bp` are fine. The prefix names what blueprint, the `_bp` suffix is conventional for Flask blueprints. +- `request_obj` is fine when disambiguating from imported `request`. +- `bp` alone as a variable name is not fine. Always pair with a meaningful prefix. + +### Code + +#### Python + +- **Variables and functions holding a database value**: match the column name exactly. No conversion to snake_case. + - DB column `machineid` -> Python variable `machineid`, attribute `Machine.machineid`, dict key `{"machineid": 1}` + - DB column `lastzabbixsync` -> Python variable `lastzabbixsync` +- **Pure code variables and functions** (loop counters, helpers, anything that does not mirror a DB field): snake_case per PEP 8. + - Good: `loop_count`, `current_user`, `validate_input()` +- **Classes**: PascalCase. Spell out fully. + - Good: `PrinterData`, `AssetType`, `NetworkDevice` +- **Modules / file names**: lowercase. Underscores allowed where it improves readability (PEP 8 permits this for modules). + - Good: `employee_service.py`, `asset_routes.py` + +#### JavaScript / Vue + +- **Variables**: camelCase per JS convention. + - Good: `currentUser`, `isLoading` +- **Variables holding API field values**: match the API field name exactly. Do NOT convert to camelCase. + - API returns `{"machineid": 1}` -> JS `response.machineid`, NOT `response.machineId` + - API returns `{"lastzabbixsync": "..."}` -> JS `device.lastzabbixsync` +- **Components**: PascalCase. Spell out fully. + - Good: `AssetDetail.vue`, `MachineForm.vue`, `PrinterList.vue` +- **CSS classes**: lowercase with dashes. + - Good: `asset-detail`, `machine-form` + +#### API + +- **Endpoints**: lowercase, plural nouns. No underscores or dashes. + - Good: `/api/machines`, `/api/networkdevices`, `/api/businessunits` +- **Query parameters**: lowercase, concatenated. Match column names where applicable. + - Good: `?locationid=5`, `?isactive=true` +- **Response keys**: match database column names exactly. + - Good: `{"machineid": 1, "lastzabbixsync": "2026-01-12T10:00:00Z"}` + +### Examples ```python -# Good -class PrinterExtension(db.Model): - __tablename__ = 'printerextensions' +# Good - DB-mirrored fields keep column names; pure code uses snake_case +class PrinterData(db.Model): + __tablename__ = 'printerdata' machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid')) lastzabbixsync = db.Column(db.DateTime) isnetworkprinter = db.Column(db.Boolean) -# Bad -class PrinterExtension(db.Model): - __tablename__ = 'printer_extensions' - machine_id = db.Column(db.Integer, db.ForeignKey('machines.machine_id')) - last_zabbix_sync = db.Column(db.DateTime) - is_network_printer = db.Column(db.Boolean) +def list_printers_by_location(locationid: int) -> list[dict]: + printers = PrinterData.query.filter_by(locationid=locationid).all() + result_count = len(printers) + return [{"machineid": p.machineid, "isnetworkprinter": p.isnetworkprinter} for p in printers] ``` -### API Response Keys - -API JSON responses should also use lowercase concatenated keys to match database columns: - -```json -{ - "machineid": 1, - "machinenumber": "M001", - "lastzabbixsync": "2026-01-12T10:00:00Z", - "isnetworkprinter": true -} -``` - -### Python Code - -Python variable and function names follow standard Python conventions (snake_case for variables/functions, PascalCase for classes): - ```python -# Variables and functions use snake_case -machine_type = get_machine_type() -is_valid = validate_input(data) +# Bad - PEP 8-style underscores on DB fields, banned shorthand, project acronym not on allowed list +class PrinterData(db.Model): + __tablename__ = 'printer_data' -# Classes use PascalCase -class PrinterExtension: - pass + machine_id = db.Column(db.Integer) + last_zabbix_sync = db.Column(db.DateTime) + is_net_prn = db.Column(db.Boolean) + + +def list_prns_by_loc(loc_id): + bp_ctx = get_context() + cfg = load_cfg() + return PrinterData.query.filter_by(loc_id=loc_id).all() ``` -### File Structure +### Style policy (non-naming) -- Models: `shopdb/core/models/` or `plugins//models/` -- API routes: `shopdb/core/api/` or `plugins//api/` -- Services: `shopdb/core/services/` or `plugins//services/` +- No emojis in code, comments, documentation, string literals, or UI. +- No em-dashes (U+2014), en-dashes (U+2013), Unicode arrows, or smart quotes anywhere. Plain ASCII only. +- Comments default to none. Add one only when the WHY is non-obvious. Keep comments terse (lite caveman style is fine for inline `#` and `//` comments). Docstrings stay normal English. +- Dark theme is default. Keep UI functional and professional. -### Plugin Development +### File structure + +- Core models: `shopdb/core/models/` +- Core API routes: `shopdb/core/api/` +- Core services: `shopdb/core/services/` +- Plugin code: `plugins//{models,api,services,schemas}/` + +### Plugin development When creating a new plugin: -1. Create directory structure in `plugins//` -2. Include `manifest.json` with metadata -3. Extend `BasePlugin` class -4. Follow naming conventions above -5. Run `flask plugin install ` to install +1. Create directory `plugins//`. Use the scaffold (`flask plugin new `) when available. +2. Include `manifest.json` with metadata. This is the single source of truth for plugin name, version, dependencies, api_prefix. +3. Extend `BasePlugin` class. +4. Follow naming conventions above. +5. Run `flask plugin install ` to install. + +See `docs/PLUGIN-QUICKSTART.md` (when published) for the 30-minute walkthrough. ## Testing @@ -98,9 +146,17 @@ Run tests with: pytest tests/ ``` -## Code Review Checklist +Tests are required for: +- New routes (smoke + happy path + 1-2 edge cases) +- New plugin hooks (contract test) +- Schema migrations (before/after data integrity) -- [ ] No underscores in table/column names -- [ ] API responses use consistent key naming -- [ ] Plugin follows BasePlugin interface +## Code review checklist + +- [ ] No underscores in table or column names +- [ ] No banned shorthand or unapproved acronyms in any new identifier +- [ ] DB-mirrored Python and JS variables match column names exactly +- [ ] API response keys match column names exactly +- [ ] No em-dashes, en-dashes, smart quotes, Unicode arrows, or emojis (run `grep -rP '[^\x00-\x7F]' --include='*.py' --include='*.vue' --include='*.js' .`) +- [ ] Plugin follows `BasePlugin` interface and contract version - [ ] Tests included for new functionality diff --git a/README.md b/README.md index 5a8e8dc..bd6f8e5 100644 --- a/README.md +++ b/README.md @@ -131,13 +131,15 @@ npm run build ### Database +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: +To import data from the legacy ShopDB MySQL database (one-time, see `migrations/DATA_MIGRATION_GUIDE.md`): ```bash python scripts/import_from_mysql.py diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index dbe3ced..700750f 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -1,5 +1,21 @@ # Frontend Development Standards +## Naming Convention (LOCKED) + +The shopdb-flask naming convention lives in the root `CONTRIBUTING.md`. Read it before naming any variable, component, API param, or CSS class. + +Frontend-specific reminders pulled from the convention: + +- Variables holding API field values: match the API field name exactly. Do NOT convert to camelCase. (`response.machineid`, NOT `response.machineId`) +- Pure JS variables: camelCase (`currentUser`, `isLoading`) +- Vue components: PascalCase, spelled out (`AssetDetail.vue`, `MachineForm.vue`) +- CSS classes: lowercase with dashes (`asset-detail`, `machine-form`) +- API params sent to backend: match DB column names without underscores (`params.locationid = 5`, NOT `params.location_id`) +- No emojis, em-dashes, smart quotes, or Unicode arrows anywhere. Plain ASCII only. +- Banned shorthand as standalone variables: `cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`. Spell them out (`canvasContext`, `response`, `manager`, etc.). Suffix usage like `printers_bp` is allowed. + +Pre-commit hook at `scripts/check-naming-and-style.sh` enforces these rules. + ## CSS Styling Standards ### Use CSS Variables for ALL Colors diff --git a/frontend/src/components/AssetRelationships.vue b/frontend/src/components/AssetRelationships.vue index 3df2aa7..f378717 100644 --- a/frontend/src/components/AssetRelationships.vue +++ b/frontend/src/components/AssetRelationships.vue @@ -95,8 +95,8 @@
diff --git a/frontend/src/components/EmployeeSearch.vue b/frontend/src/components/EmployeeSearch.vue index 6aa1127..9be6b15 100644 --- a/frontend/src/components/EmployeeSearch.vue +++ b/frontend/src/components/EmployeeSearch.vue @@ -86,8 +86,8 @@ async function onSearch() { searchTimeout = setTimeout(async () => { try { - const res = await employeesApi.search(query) - results.value = res.data.data || [] + const response = await employeesApi.search(query) + results.value = response.data.data || [] } catch (err) { console.error('Employee search error:', err) results.value = [] diff --git a/frontend/src/views/ShopfloorDashboard.vue b/frontend/src/views/ShopfloorDashboard.vue index 02f8246..027a562 100644 --- a/frontend/src/views/ShopfloorDashboard.vue +++ b/frontend/src/views/ShopfloorDashboard.vue @@ -170,8 +170,8 @@ onMounted(async () => { // Load business units try { - const res = await businessUnitsApi.list() - businessUnits.value = res.data.data || [] + const response = await businessUnitsApi.list() + businessUnits.value = response.data.data || [] } catch (err) { console.error('Error loading business units:', err) } @@ -200,8 +200,8 @@ async function loadData() { if (businessUnit.value) { params.businessunit = businessUnit.value } - const res = await notificationsApi.getShopfloor(params) - notifications.value = res.data.data || { current: [], upcoming: [] } + const response = await notificationsApi.getShopfloor(params) + notifications.value = response.data.data || { current: [], upcoming: [] } } catch (err) { console.error('Error loading shopfloor data:', err) } finally { diff --git a/frontend/src/views/network/NetworkDevicesList.vue b/frontend/src/views/network/NetworkDevicesList.vue index 4fc6e4c..1fb111d 100644 --- a/frontend/src/views/network/NetworkDevicesList.vue +++ b/frontend/src/views/network/NetworkDevicesList.vue @@ -199,9 +199,9 @@ async function loadDevices() { perpage: perPage.value } if (search.value) params.search = search.value - if (selectedType.value) params.type_id = selectedType.value - if (vendorFilter.value) params.vendor_id = vendorFilter.value - if (locationFilter.value) params.location_id = locationFilter.value + if (selectedType.value) params.typeid = selectedType.value + if (vendorFilter.value) params.vendorid = vendorFilter.value + if (locationFilter.value) params.locationid = locationFilter.value const response = await networkApi.list(params) devices.value = response.data.data || [] diff --git a/frontend/src/views/notifications/NotificationForm.vue b/frontend/src/views/notifications/NotificationForm.vue index c75b03e..3708100 100644 --- a/frontend/src/views/notifications/NotificationForm.vue +++ b/frontend/src/views/notifications/NotificationForm.vue @@ -376,8 +376,8 @@ async function searchEmployees() { searchTimeout = setTimeout(async () => { try { - const res = await employeesApi.search(query) - employeeResults.value = res.data.data || [] + const response = await employeesApi.search(query) + employeeResults.value = response.data.data || [] } catch (err) { console.error('Employee search error:', err) employeeResults.value = [] diff --git a/frontend/src/views/notifications/NotificationsList.vue b/frontend/src/views/notifications/NotificationsList.vue index 405eb34..c65e54f 100644 --- a/frontend/src/views/notifications/NotificationsList.vue +++ b/frontend/src/views/notifications/NotificationsList.vue @@ -127,7 +127,7 @@ async function loadNotifications() { params.search = searchQuery.value } if (selectedType.value) { - params.type_id = selectedType.value + params.typeid = selectedType.value } if (currentFilter.value === 'current') { params.current = 'true' diff --git a/frontend/src/views/print/PrinterQRBatch.vue b/frontend/src/views/print/PrinterQRBatch.vue index 89ac8be..ebb6390 100644 --- a/frontend/src/views/print/PrinterQRBatch.vue +++ b/frontend/src/views/print/PrinterQRBatch.vue @@ -129,22 +129,22 @@ function generateQRCodes() { } function drawLogoOverlay(canvas) { - const ctx = canvas.getContext('2d') + const canvasContext = canvas.getContext('2d') const size = canvas.width const logoSize = Math.round(size * 0.22) const x = (size - logoSize) / 2 const y = (size - logoSize) / 2 // White circle background - ctx.beginPath() - ctx.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2) - ctx.fillStyle = '#fff' - ctx.fill() + canvasContext.beginPath() + canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2) + canvasContext.fillStyle = '#fff' + canvasContext.fill() // Load and draw the GE monogram const img = new Image() img.onload = () => { - ctx.drawImage(img, 0, 0, 32.5, 32, x, y, logoSize, logoSize) + canvasContext.drawImage(img, 0, 0, 32.5, 32, x, y, logoSize, logoSize) } const svgStr = `` img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr) diff --git a/frontend/src/views/print/PrinterQRSingle.vue b/frontend/src/views/print/PrinterQRSingle.vue index b0b6133..d011654 100644 --- a/frontend/src/views/print/PrinterQRSingle.vue +++ b/frontend/src/views/print/PrinterQRSingle.vue @@ -97,23 +97,23 @@ function generateQR() { } function drawLogoOverlay(canvas) { - const ctx = canvas.getContext('2d') + const canvasContext = canvas.getContext('2d') const size = canvas.width const logoSize = Math.round(size * 0.22) const x = (size - logoSize) / 2 const y = (size - logoSize) / 2 // White circle background - ctx.beginPath() - ctx.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2) - ctx.fillStyle = '#fff' - ctx.fill() + canvasContext.beginPath() + canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2) + canvasContext.fillStyle = '#fff' + canvasContext.fill() // Load and draw the GE monogram (the circular part of the SVG) const img = new Image() img.onload = () => { // Draw only the GE monogram portion (left 32x32 of the 138x32 SVG) - ctx.drawImage(img, 0, 0, 32.5, 32, x, y, logoSize, logoSize) + canvasContext.drawImage(img, 0, 0, 32.5, 32, x, y, logoSize, logoSize) } // Use a data URI with black fill for the monogram const svgStr = `` diff --git a/migrations/adr/ADR-001-asset-as-platform-contract.md b/migrations/adr/ADR-001-asset-as-platform-contract.md new file mode 100644 index 0000000..ff703f8 --- /dev/null +++ b/migrations/adr/ADR-001-asset-as-platform-contract.md @@ -0,0 +1,173 @@ +# ADR-001: Asset model is the platform contract + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** cproudlock +- **Supersedes:** none + +## Context + +shopdb-flask is being shaped as a framework that sister GE Aerospace facilities can adopt. The framework defines a stable core; sites install plugins for the asset classes they care about (equipment, computers, printers, measuring tools, network gear, etc.). + +The codebase ran two parallel object models: + +1. **Legacy `Machine` model** in `shopdb/core/models/machine.py`. Original schema inherited from the classic-ASP shopdb. Tables: `machines`, `pctypes`, `machinetypes`. Plugins like printers stored extension data via `PrinterData` keyed by `machineid`. +2. **New `Asset` model** in `shopdb/core/models/asset.py`. Generic asset abstraction with `AssetType`, `AssetStatus`, `AssetRelationship`. Plugins also exposed asset-based tables keyed by `assetid`. + +For the framework to be adoptable, the platform contract has to be one model, documented, versioned, and stable. + +## Decision + +**`Asset` is the platform contract.** Plugin authors target the asset-based API. The `Machine` model and its dependents (`PCType`, `MachineType`, `PrinterData` keyed by `machineid`) are legacy and will be retired through a tracked migration. + +### Platform contract surface + +The following are the public, versioned surface. Plugin authors may depend on them. Breaking changes require a major version bump per ADR-002. + +#### Models + +- `Asset` (core entity) +- `AssetType` (asset classification, registered by plugins) +- `AssetStatus` (active / inactive / decommissioned / retired) +- `AssetRelationship` + `RelationshipType` (cross-plugin links) +- `Vendor`, `Location`, `LocationType`, `BusinessUnit`, `Model`, `OperatingSystem` (shared reference data) + +#### Helpers + +- `AuditLog` API: `audit_log(action, entitytype, entityid, ...)` for plugins to record audit entries with consistent schema +- `Setting` API: `plugin.get_setting(key)` and `plugin.set_setting(key, value)` for plugin-scoped config persisted via the core `Setting` model + +#### Plugin contract + +- `BasePlugin` ABC and its hooks (search, navigation, dashboard, relationships, collector schema) + +#### Excluded from the contract for v1 + +- Event bus (`get_event_handlers` removed from `BasePlugin`). Add later if a real use case appears. + +### Relationship types (seeded core values) + +`RelationshipType` is seeded with three rows. Plugin authors do not add new types in v1. + +| Type | Meaning | Boundary rule | +|------|---------|---------------| +| `partof` | Composition, siblings, sub-assemblies | Parent dies without it | +| `controls` | One asset has operational authority over another | PC commands a machine, measuring tool, etc. | +| `connectedto` | Network or data link without operational authority | Cable, switch port, NAS mount | + +### AssetRelationship columns + +``` +AssetRelationship +- relationshipid PK +- sourceassetid FK to assets +- targetassetid FK to assets +- relationshiptypeid FK to relationshiptypes (one of partof, controls, connectedto) +- label text, free description (e.g., "ethernet PoE", "DNC feed") +- inheritsposition bool, true means resolved-position walk follows this edge +- propagatesthroughid FK to relationshiptypes, nullable, see propagation below +- notes text +- isactive bool +- createddate, modifieddate +``` + +Free-text `label` carries the domain nuance (`controls` with label "DNC feed" vs `controls` with label "operator workstation"). Avoids inflating the type list. + +### Sibling propagation + +When a relationship is created or deleted, the framework checks `RelationshipType.propagatesthroughid`. If set, it finds all assets related to the source via the propagation type and applies the same change. + +Default seeding: + +| RelationshipType | propagatesthroughid | +|------------------|---------------------| +| `partof` | null (the propagation rail itself) | +| `controls` | `partof` (controls relationships propagate across siblings) | +| `connectedto` | null (network paths don't propagate) | + +Cycle protection: max walk depth 3, visited-set during traversal. + +### Position resolution + +Resolved map position for any asset follows this priority chain: + +1. Asset-specific override (`assets.mapx`, `assets.mapy` if non-null) +2. Walk relationships where `inheritsposition=true`, ordered by relationship type priority (`partof` first, then `controls`), recursively resolve the related asset's position with cycle detection +3. Fall back to the asset's location coords (`locations.mapx`, `locations.mapy` via `assets.locationid`) +4. If none of the above, asset is "unplaced" and rendered in a tray, not on the map + +API exposes resolved position with a source indicator: `{"mapx": 234, "mapy": 567, "positionsource": "self|related|location|none"}`. + +### Hierarchical locations + +`locations` is extended with `parentlocationid` (self-FK, nullable). Sites define their own location tree (cells, sub-cells, network closets, meeting rooms, labs). + +`locationtypes` lookup is added with seeded values: `section`, `cell`, `subcell`, `meetingroom`, `lab`, `office`, `storage`, `hallway`, `networkcloset`, `building`. Sites can extend. + +Asset-to-location is the existing `assets.locationid` FK only (single primary location for v1). Multi-location and transient placement are deferred. + +## Migration scope (narrowed) + +Only one class of legacy data migrates: physical manufacturing equipment with a `machinenumber` (5-axis mills, lathes, broachers, heat treatment, CMMs, etc.). + +Migration filter: + +```sql +SELECT * FROM legacy.machines +WHERE category = 'Equipment' + AND machinenumber IS NOT NULL +``` + +Rows without `machinenumber`: **skipped.** Add manually post-migration if any matter. + +Migrated rows become `assets` with `assettype='Equipment'` plus a row in the equipment plugin's `equipment` table. Legacy `machinetypeid` is preserved on the equipment row to enable later reclassification (some "Equipment" rows are actually measuring tools, see ADR-005). + +Skipped from migration: + +- PCs (rebuilt via PXE collector pipeline, see ADR-006) +- Printers, USB devices, network gear, notifications, KB articles (operational or re-collectable, decision per class deferred until needed) + +## Consequences + +### Positive + +- Plugin authors at sister sites have a single, documented contract to target +- Cross-plugin features (relationships, search, map) work uniformly +- Three relationship types are easy to learn; free-text label captures nuance +- Sibling propagation handles dual-path machines without code changes per use case +- Position resolution gives users one map without per-plugin map code + +### Negative + +- Migration of equipment data must run cleanly; `migrating-asset-schema` skill owns the procedure +- Frontend `views/machines/` will be repointed or replaced; effort tracked in Phase 5 +- Legacy `core/api/machines.py` (641 LOC) becomes deprecated; deletion or shim deferred until equipment is migrated + +### Neutral + +- `Machine`, `PCType`, `MachineType`, `PrinterData` remain in the schema until the migration completes, behind a deprecation notice. New code does not touch them. + +## Alternatives considered + +1. **Keep both models indefinitely.** Doubles test surface, confuses contract docs, breaks cross-plugin features. Rejected. +2. **Make `Machine` the contract; retire `Asset`.** `Machine` has shop-floor-specific assumptions that don't generalize. Rejected. +3. **Define a higher abstraction above both.** Yet another layer. `Asset` is already the abstraction. Rejected. +4. **More relationship types** (`controls`, `operates`, `monitors`, `dncfeeds`, `mountedon`, `connectedvia`, `inspects`, `siblingbay`). Eight types proved unwieldy. Collapsed to three with free-text labels and per-row inheritance/propagation flags. + +## Open questions deferred + +- Lifecycle relationship type (`replaces`, `replacedby`). Add when first needed. +- Multi-location asset placement, transient placement (calibration trip, off-site repair). Add when first needed. +- Map editing UI (drag locations onto floor plan). Phase 6 polish. +- Plugin-extensible relationship types beyond the three seeded. Add when a real cross-plugin case can't be expressed via labels. + +## References + +- `shopdb/core/models/asset.py` +- `shopdb/core/models/machine.py` (legacy, deprecated) +- `shopdb/plugins/base.py` +- ADR-002 (versioning of the surface) +- ADR-003 (plugin distribution) +- ADR-004 (deployment topology) +- ADR-005 (equipment vs measuring tools split) +- ADR-006 (collector contract pattern) diff --git a/migrations/adr/ADR-002-plugin-versioning.md b/migrations/adr/ADR-002-plugin-versioning.md new file mode 100644 index 0000000..c2d0671 --- /dev/null +++ b/migrations/adr/ADR-002-plugin-versioning.md @@ -0,0 +1,60 @@ +# ADR-002: Plugin contract versioning + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** cproudlock +- **Supersedes:** none + +## Context + +Once sister sites start writing their own plugins (or pulling community plugins), the framework's plugin contract becomes a public API. Without a versioning story, any change to `BasePlugin` or the core platform models can silently break installed plugins at remote sites. + +The existing `BasePlugin` and `PluginMeta` already declare a `core_version` field (default `">=1.0.0"`), but it is not enforced anywhere. The plugin loader does not check it before instantiation. + +## Decision + +The framework adopts **semantic versioning for the plugin contract**, declared in two places: + +1. **Framework version** (`shopdb/__init__.py`): a single `__contract_version__` constant. This is the version of the platform contract as defined in ADR-001. Bumped according to semver: + - **Major**: breaking change to `BasePlugin` ABC, `PluginMeta` schema, or any model in the platform contract (`Asset`, `AssetType`, `AssetStatus`, `AssetRelationship`, `Vendor`, `Location`, `BusinessUnit`, `Model`, `OperatingSystem`). + - **Minor**: additive change (new optional hook, new field on a contract model with default). + - **Patch**: bug fix, no contract surface change. +2. **Plugin requirement** (`plugins//manifest.json`): the existing `core_version` field, expressed as a semver range (e.g., `">=1.0.0,<2.0.0"`). + +The plugin loader (`shopdb/plugins/loader.py`) checks `core_version` against `__contract_version__` at load time. Mismatch in dev = re-raise (fail loud). Mismatch in prod = log error, mark plugin as incompatible, exclude from registration. + +The `__contract_version__` starts at **`1.0.0`** when ADR-001 is accepted and the `Machine` retirement migration is complete (whichever comes later). Until then, the framework is pre-1.0; plugins should declare `core_version: ">=0.1.0,<1.0.0"`. + +## Consequences + +### Positive + +- Sister sites can pin a known-good framework version. They will not be silently broken when the framework is upgraded. +- Plugin authors know what counts as a breaking change because the contract surface is enumerated in ADR-001. +- The loader fails predictably: a mismatched plugin is reported, not silently disabled. + +### Negative / cost + +- Discipline required: every change to the contract surface must be classified (major / minor / patch). Adding a `version-bump` skill (or a check in code review) reduces the chance of mis-classification. +- `__contract_version__` becomes a coupling point. Forgetting to bump it after a breaking change means downstream plugins crash silently at runtime instead of failing at install. + +### Neutral + +- Existing plugins (`plugins/printers/`, etc.) ship as part of the framework, so their `core_version` is always the current `__contract_version__`. The discipline matters mostly for external / sister-site plugins. + +## Alternatives considered + +1. **No versioning, just trust.** Works for an in-tree-only world. Fails the moment a sister site ships its own plugin. Rejected. +2. **Calendar versioning** (e.g., `2026.05.0`). Easier to bump, harder to communicate breaking changes. Rejected; semver is the industry standard for library-like contracts. +3. **Per-hook versioning.** Each hook has its own version. Too granular; plugins still couple to multiple hooks. Rejected. + +## Open questions + +- When does the framework declare `1.0.0`? Tied to ADR-001 (Asset retirement of Machine) and the framework being deemed "ready for sister sites". Best-effort target: end of Phase 5 in the refactor plan. +- Should `core_version` accept commercial-grade ranges (`^1.0.0`) or stick to PEP 440 / npm-style ranges? Recommend pip-style (`>=,<`) to match Python ecosystem. + +## References + +- `shopdb/plugins/base.py` (PluginMeta declaration) +- `shopdb/plugins/loader.py` (where the version check belongs) +- ADR-001 (defines what is in the contract) diff --git a/migrations/adr/ADR-003-plugin-distribution.md b/migrations/adr/ADR-003-plugin-distribution.md new file mode 100644 index 0000000..f7a1f2a --- /dev/null +++ b/migrations/adr/ADR-003-plugin-distribution.md @@ -0,0 +1,68 @@ +# ADR-003: Plugin distribution model + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** cproudlock + +## Context + +Sister sites adopting shopdb-flask need a way to: + +1. Install the framework +2. Pick which plugins they want +3. Build their own plugins for site-specific equipment +4. Receive updates to both framework and plugins + +Today, every plugin lives in the `plugins/` directory of the framework repo. There is no separation between framework code and plugin code, no install / uninstall, and no way for a site to develop a plugin without forking the whole repo. + +Three viable distribution models: + +| Model | How a site installs a plugin | +|---|---| +| **In-tree only** | Fork the framework repo, add plugin under `plugins/`, run their own deploy. No separation. | +| **Pip-installable plugins** | Each plugin published as a Python package. Site does `pip install shopdb-printers shopdb-network` etc. Discovery via Python entry points. Framework loads any installed plugin that registers itself. | +| **Git-based plugins** | Each plugin lives in its own git repo. Site clones / submodules into `plugins//`. Loader picks them up from the directory. | + +## Decision + +**PROPOSED:** Use a **hybrid model** with two clearly-labeled paths. + +1. **Bundled plugins**: a small set of plugins ships with the framework, in-tree at `plugins/`. These are the reference implementations and the default install (printers, computers, network, equipment, usb, notifications). A site that wants only what's bundled needs no extra work. +2. **External plugins**: sister sites or third parties build plugins in their own git repos. The site running the framework drops the plugin into `plugins//` (clone, submodule, or symlink) and runs `flask plugin install `. No pip packaging required for v1. + +Pip-installable plugins (Python entry-point discovery) are deferred to v2. The complexity is not justified until at least two sites are running their own plugins. + +## Consequences + +### Positive + +- v1 is simple: filesystem-based discovery (already implemented in `shopdb/plugins/loader.py`), works for both bundled and external plugins. +- Sites can develop plugins without changing the framework repo. +- The `plugins/` directory is already the canonical location, so no architectural change is needed. + +### Negative / cost + +- No automatic update path for external plugins. Sites must `git pull` in each plugin directory manually. Acceptable for v1; revisit when plugin count grows. +- Multiple plugin authors writing in parallel can collide on namespace (e.g., two plugins both registering an `AssetType` named "Equipment"). Need a naming policy: plugin names and asset-type names should be prefixed with the site or org if not in the bundled set. + +### Neutral + +- The existing in-tree pattern keeps working. This decision just formalizes it and clarifies the path for outside-the-tree plugins. + +## Alternatives considered + +1. **Pip-installable from day one.** Cleaner for the long term but adds packaging, entry-point registration, and CI steps. Premature for current scale (one site running, no sister-site plugins yet). +2. **In-tree only forever.** Forces every site to fork. Doesn't scale beyond two or three sites. +3. **Submodules only.** Forces git-submodule discipline on every adopting site. Submodules are notoriously fiddly. Rejected. + +## Open questions + +- For external plugins, should there be a manifest field (`source_url`) declaring where the plugin can be cloned from, so `flask plugin install` could pull it for the site? Defer; manual clone is fine for v1. +- Naming convention for non-bundled plugin directory names: prefix with site? (`gea-wjsf-shipping`)? Adopt if and when we hit a name collision. + +## References + +- `shopdb/plugins/loader.py` (filesystem discovery) +- `shopdb/plugins/cli.py` (plugin install / uninstall command) +- ADR-001 (defines what plugins target) +- ADR-002 (defines plugin version compatibility) diff --git a/migrations/adr/ADR-004-deployment-topology.md b/migrations/adr/ADR-004-deployment-topology.md new file mode 100644 index 0000000..ac38049 --- /dev/null +++ b/migrations/adr/ADR-004-deployment-topology.md @@ -0,0 +1,69 @@ +# ADR-004: Deployment topology (per-site instances) + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** cproudlock + +## Context + +shopdb-flask manages shop-floor inventory. If multiple GE Aerospace sites adopt it, the deployment can take one of two shapes: + +| Model | How it works | +|---|---| +| **Per-site instances** | Each site runs its own Flask + MySQL + Vue stack. Each site has its own DB, its own users, its own enabled-plugin list, its own deploy. Sites are isolated. | +| **Multi-tenant single instance** | One central Flask + MySQL + Vue stack serves all sites. A `siteid` foreign key on every asset partitions data. Auth distinguishes which site a user belongs to. | + +The codebase today is single-tenant per deployment. There is no `siteid` column, no tenant filter, no cross-site auth model. Plugins can be enabled / disabled but only globally for the running instance. + +## Decision + +**PROPOSED:** **Per-site instances.** Each adopting site runs its own dedicated stack. The framework does not support multi-tenancy. + +Each site: + +- Owns its database (own credentials, own backup policy, own retention) +- Picks its own enabled plugins +- Configures its own JWT secret, CORS allowlist, Zabbix integration, Active Directory binding +- Deploys at its own cadence + +The framework provides: + +- A `Dockerfile` and `docker-compose.yml` template suitable for a single-site deploy +- A `.env.example` listing all required environment variables +- A `docs/DEPLOY.md` walking through a fresh-site install + +## Consequences + +### Positive + +- Simpler code: no tenant filter on every query, no cross-tenant auth, no shared-state partitioning bugs. +- Sites are independent. A schema change at one site does not affect another. A plugin crash at one site does not blast radius to other sites. +- Clear ownership: each site's IT team owns their own stack and data. Compliance and audit boundaries match operational boundaries. +- Aligns with how GE Aerospace sites already operate (independent IT, independent shop floors). + +### Negative / cost + +- No cross-site reporting out of the box. If GE corporate ever wants a fleet-wide view, it has to be built on top (e.g., a roll-up dashboard that queries each site's API). That layer is out of scope for the framework. +- Each site administers its own stack. Higher operational overhead than a single central instance, but each site already runs its own infrastructure. +- Updates require visiting each site's deploy. Fine for the current adoption model; revisit if dozens of sites adopt. + +### Neutral + +- No `siteid` column needed. The existence of one DB per site is the partition. + +## Alternatives considered + +1. **Multi-tenant single instance.** Lower operational overhead at scale, easier cross-site reporting, but adds significant code complexity and risk: every query needs a tenant filter, auth gets complex, schema migrations affect every site at once, and a bug at one site can leak data across sites. Rejected for v1; revisit if and only if more than five sites adopt and operational overhead becomes painful. +2. **Hybrid: per-site DB but central app server.** Adds the operational complexity of multi-tenancy without isolating the failure domain (one app crash = all sites down). Rejected. + +## Open questions + +- Should the framework provide an optional **read-only fleet roll-up** mode where a "central" instance can pull aggregate metrics from each site's API? Defer. Out of scope for v1. +- Backup strategy per site: framework recommendation, or each site decides? Framework should publish a recommended backup runbook (mysqldump + offsite copy) but not enforce. +- Auth federation: each site has its own user table, or sites can share an LDAP / SSO? Recommend documenting the LDAP config knob in `.env.example` so sites can plug in their own auth without code change. + +## References + +- `shopdb/config.py` (currently single-tenant, no `siteid`) +- ADR-001 (asset model is per-site, not cross-site) +- ADR-003 (plugin distribution per site) diff --git a/migrations/adr/ADR-005-equipment-vs-measuringtools.md b/migrations/adr/ADR-005-equipment-vs-measuringtools.md new file mode 100644 index 0000000..44aec8f --- /dev/null +++ b/migrations/adr/ADR-005-equipment-vs-measuringtools.md @@ -0,0 +1,149 @@ +# ADR-005: Equipment plugin scope vs measuringtools plugin + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** cproudlock +- **Supersedes:** none + +## Context + +ADR-001 narrowed the migration to physical manufacturing equipment with a `machinenumber`. In practice, the legacy `category='Equipment'` rows contain two distinct asset classes: + +1. **Manufacturing machinery** (5-axis mills, lathes, broachers, heat treatment ovens). These produce parts. +2. **Metrology and inspection instruments** (CMMs, Keyence vision systems, wax-and-trace surface profilometers, GenSpec instruments). These measure parts. + +Both share `Asset` properties (vendor, model, location, controller). They differ in domain fields (axes vs measurement accuracy, cycle time vs calibration interval, controller protocol vs measurement software). + +Mixing them under one plugin pollutes the schema and confuses cross-plugin queries ("show me all measuring tools" requires an enumeration of measuring-instrument equipmenttype values, which scales badly). + +## Decision + +Two plugins, separate concerns, shared platform contract. + +### `equipment` plugin + +Tracks manufacturing machinery. Bundled, in-tree. + +Schema (per ADR-001 contract): + +``` +equipment +- assetid FK to assets, PK +- equipmenttypeid FK to equipmenttypes (5-axis mill, lathe, broacher, heat treat, ...) +- vendorid FK to vendors (platform) +- modelid FK to models (platform) +- controllertypeid FK to controllertypes (equipment plugin) +- controllerosid FK to controlleros (equipment plugin) +- (other shared fields: spindle count, axes, max workpiece size, ...) + +equipmenttypes (lookup, equipment plugin) +- equipmenttypeid, name (5-axis mill, lathe, broacher, heat treat, ...) + +controllertypes (lookup, equipment plugin) +- controllertypeid, name (Fanuc 31i, Siemens 840D, Mitsubishi M70, Heidenhain TNC640, ...) +- vendorid (FK to vendors) + +controlleros (lookup, equipment plugin - separate from PC OS) +- controllerosid, name (FAPT, VxWorks, embedded Windows, Linux RT, ...) + +equipmentfocas (subtype, optional, present only when FOCAS-equipped) +- assetid PK, FK to equipment +- focasipaddress text +- focasport integer +- focasversion text +- focasmachinenumber text + +equipmentclm (subtype, optional, present only when CLM-equipped) +- assetid PK, FK to equipment +- (CLM-specific: address, port, station ID - finalize when plugin is built) + +equipmentmtconnect (subtype, optional, present only when MTConnect-equipped) +- assetid PK, FK to equipment +- mtconnectagenturl text +- mtconnectdevicename text +``` + +The `equipment.protocol` enum field is deliberately **not** included. Presence or absence of a subtype row indicates which protocol applies. Avoids a denormalized field that can drift out of sync. + +### `measuringtools` plugin + +Tracks metrology and inspection instruments. Bundled, in-tree (built in Phase 3-4 of the refactor as the first new plugin built using the framework scaffold). + +Schema (initial draft, refined when plugin is built): + +``` +measuringtools +- assetid FK to assets, PK +- measuringtooltypeid FK to measuringtooltypes (CMM, vision system, profilometer, surface tester, ...) +- vendorid FK to vendors (platform) +- modelid FK to models (platform) +- measurementaxes integer (e.g., 3 for a 3-axis CMM) +- accuracyspec text (e.g., "+/-0.5um") +- calibrationintervaldays integer +- lastcalibrationdate date +- nextcalibrationdate date (computed) +- (other domain fields as needed) + +measuringtooltypes (lookup, measuringtools plugin) +- measuringtooltypeid, name (CMM, vision system, surface profilometer, gage block, ...) +``` + +Future extension: subtype tables for measurement-software integrations (PC-DMIS, Keyence, GenSpec). Same pattern as equipment subtype tables. + +### Subtype-table pattern (general) + +Both plugins use the same pattern for protocol- or software-specific fields: + +- Core plugin table carries shared, common fields +- Optional subtype tables (one per protocol or software) hold extension fields +- Each subtype table is keyed by `assetid` (PK), one-to-one with the parent +- Subtype row exists if and only if the asset uses that protocol or software +- Sister sites add new subtype tables for their own integrations without touching core + +## Reclassification of legacy data + +ADR-001's migration moves all legacy `category='Equipment' AND machinenumber IS NOT NULL` rows to `assets` with `assettype='Equipment'` and into the equipment plugin's `equipment` table. This includes both manufacturing machinery and measuring tools. + +After the equipment migration, when the measuringtools plugin is built: + +1. Build a mapping table: legacy `machinetypeid` values that are measuring tools (CMM type, Keyence type, etc.) +2. Run a reclassification script: + - For each `assets` row where the original `machinetypeid` is in the measuring-tool mapping + - Change `assets.assettype` to `'MeasuringTool'` + - Move the row from `equipment` to `measuringtools` + - Map domain fields where they differ (e.g., legacy `axes` field maps to `measurementaxes`) +3. Verify counts pre- and post-reclassification +4. Audit log entry per reclassified row + +Reclassification is one-shot, run once, archived. Like the original migration script. + +## Consequences + +### Positive + +- Manufacturing machinery and measuring tools are first-class plugins, each with appropriate domain fields +- Sister sites can install one or both depending on what they track +- Subtype-table pattern is the canonical example for protocol-specific data and extends naturally to other plugins +- Building `measuringtools` mid-refactor validates the plugin scaffold tooling against a real new plugin + +### Negative + +- Reclassification is a second migration step. Lower risk than the initial migration because it is data-only (no schema change beyond moving rows between two tables that share the same `assetid` link). +- Sites that adopt the framework before `measuringtools` ships need to either keep measuring tools in `equipment` (workable but suboptimal) or wait for the plugin + +### Neutral + +- Legacy `machinetypeid` is preserved on the equipment row during migration to enable reclassification + +## Alternatives considered + +1. **Single equipment plugin with sub-typed assets.** Use `equipment.equipmenttypeid` to discriminate manufacturing vs metrology. Rejected: domain fields differ enough that a single table is wide and full of NULLs. +2. **Migrate split (build mapping before initial migration).** Cleaner end state but requires the `measuringtools` plugin to exist before the migration runs, which delays Phase 5. Rejected. +3. **JSON blob for protocol data instead of subtype tables.** Considered for both plugins. Rejected: weak typing, awkward queries, no schema validation. + +## References + +- ADR-001 (Asset is platform contract) +- ADR-002 (versioning of the surface) +- `plugins/equipment/` (current placeholder) +- `plugins/computers/` (existing example of plugin pattern) diff --git a/migrations/adr/ADR-006-collector-contract.md b/migrations/adr/ADR-006-collector-contract.md new file mode 100644 index 0000000..c18b477 --- /dev/null +++ b/migrations/adr/ADR-006-collector-contract.md @@ -0,0 +1,131 @@ +# ADR-006: Plugin collector contract pattern + +- **Status:** ACCEPTED +- **Date:** 2026-05-08 +- **Deciders:** cproudlock +- **Supersedes:** none + +## Context + +PC inventory data was collected by PowerShell scripts pushing to `/api/collector/pc` (`shopdb/core/api/collector.py`, ~374 LOC). The endpoint is hardcoded for PCs: it accepts a fixed schema and writes to the legacy `Machine` model. + +Per ADR-001, `Machine` is being retired in favor of `Asset`. Per the project shift to PXE-driven imaging, PC inventory is moving to a new collection pipeline (PXE / GE-Enforce / manifest engine produces JSON about each PC). Other asset classes may want similar collector pipelines (printers via Zabbix, network gear via SNMP scan). + +This calls for a generalizable contract: any plugin that wants to accept external collector input declares a JSON schema, and the framework wires the endpoint, auth, and idempotency. + +## Decision + +`BasePlugin` gets one new optional hook: + +```python +def get_collector_schema(self) -> Optional[dict]: + """Return JSON Schema describing the collector payload for this plugin. + Return None if the plugin does not accept collector input. + + The schema must include: + - 'identityfield': name of the field that uniquely identifies an asset + across submissions (e.g., 'hostname' for PCs, 'macaddress' for network + devices). Used for idempotent upsert. + - 'fields': JSON Schema definitions for the rest of the payload. + """ + return None +``` + +Plugin loader auto-registers an endpoint at `/api/collector/` for each plugin returning a schema. Auth is API-key, separate from JWT. Per-plugin keys via env vars: + +- `COLLECTOR_API_KEY_` (preferred, plugin-specific) +- `COLLECTOR_API_KEY` (fallback, shared) + +### Idempotent upsert + +The endpoint uses the `identityfield` to find an existing `Asset` for the same identity. Found = update. Not found = insert. Existing relationships are preserved on update. + +### Response contract + +```json +{ + "status": "ok", + "action": "created" | "updated" | "noop", + "assetid": 12345, + "identityvalue": "PC-1234", + "warnings": [] +} +``` + +### Audit logging + +Every collector submission produces an audit log entry: `{action, plugin, identityvalue, before/after diff}`. Audit retention per site policy. + +### Schema discovery + +The framework exposes the registered schemas at `/api/collector/_schemas` (read-only, JWT-protected) so external collector authors can introspect what payloads are accepted by which plugins. + +## Concrete first user: computers plugin + +The `computers` plugin is the first to implement `get_collector_schema`. The PXE pipeline conforms. + +Initial computers collector schema (sketch, finalized when plugin is built): + +```json +{ + "identityfield": "hostname", + "fields": { + "hostname": "string, required", + "macaddress": "string, optional, secondary identity", + "osname": "string", + "osversion": "string", + "lastboottime": "datetime", + "currentuser": "string", + "ipaddress": "string", + "memorygb": "number", + "cputype": "string", + "imagename": "string (PXE image deployed)", + "imageappliedat": "datetime", + "installedsoftware": "array of {name, version}" + } +} +``` + +The PC re-image case is handled by the identity field: a freshly imaged PC keeps its hostname, so the existing `Asset` row is updated rather than duplicated. Existing `AssetRelationship` rows pointing at that PC (e.g., `controls` to a machine) are preserved across re-images. + +## Migration of the existing endpoint + +`shopdb/core/api/collector.py` (`/api/collector/pc`) is **deprecated** in v1 and **removed** before v1.0. + +Migration path: + +1. Implement `get_collector_schema` on the `computers` plugin. New endpoint `/api/collector/computers` is auto-registered. +2. Run both endpoints in parallel for one cycle of PXE imaging across the floor. PXE pipeline switches to `/api/collector/computers`. +3. Remove `shopdb/core/api/collector.py` and the legacy blueprint registration. + +## Consequences + +### Positive + +- Generalizable across plugins. Sister sites adopting `printers`, `network`, etc. can wire their own collectors with no core change. +- Identity-based idempotency makes PC re-imaging safe by default. +- Audit logging is uniform across plugins. +- Schema discovery enables external tools to validate before submission. + +### Negative + +- Plugin authors must write a JSON schema. Slight learning curve, but JSON Schema is widely understood and the framework can ship a few examples. +- The `/api/collector/_schemas` endpoint plus per-plugin endpoints expand the public API surface; minor maintenance cost. + +### Neutral + +- API-key auth pattern stays as it is today (separate from JWT). Sites manage their own collector keys per plugin via env vars. + +## Alternatives considered + +1. **Keep `/api/collector/pc` and add new plugin-specific endpoints alongside.** Two ways to send PC data, plugin authors confused. Rejected. +2. **Use JWT for collectors instead of API key.** Collectors are headless processes (PXE pipeline, scripts), not interactive users. JWT lifecycle (refresh tokens, expiry) is the wrong tool. API key is simpler. Rejected. +3. **Plugins write directly to the database, no collector endpoint.** Skips audit logging and schema validation. Rejected. + +## References + +- `shopdb/core/api/collector.py` (legacy endpoint to be removed) +- `shopdb/plugins/base.py` (`get_collector_schema` hook to be added) +- ADR-001 (asset model the collectors target) +- ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps) +- The PXE project (`/home/camp/projects/pxe/`) which feeds the computers collector diff --git a/migrations/adr/README.md b/migrations/adr/README.md new file mode 100644 index 0000000..748d117 --- /dev/null +++ b/migrations/adr/README.md @@ -0,0 +1,25 @@ +# Architecture Decision Records + +Each ADR captures a single architectural decision: the context, the decision itself, the consequences, and the alternatives considered. ADRs are immutable once accepted. Superseded ADRs stay in this folder with a pointer to the newer ADR. + +## Status definitions + +- **PROPOSED**: drafted, awaiting decision +- **ACCEPTED**: decision is in effect +- **SUPERSEDED**: replaced by a later ADR (link forward) +- **DEPRECATED**: no longer in effect, no replacement + +## Index + +| ADR | Title | Status | +|-----|-------|--------| +| [001](ADR-001-asset-as-platform-contract.md) | Asset model is the platform contract | ACCEPTED | +| [002](ADR-002-plugin-versioning.md) | Plugin contract versioning (semver) | ACCEPTED | +| [003](ADR-003-plugin-distribution.md) | Plugin distribution model | ACCEPTED | +| [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 | + +## Authoring + +When proposing a new decision, copy the most recent ADR as a template, increment the number, and update this index. Do not edit accepted ADRs in place; supersede them with a new one. diff --git a/scripts/check-naming-and-style.sh b/scripts/check-naming-and-style.sh new file mode 100755 index 0000000..7cadfba --- /dev/null +++ b/scripts/check-naming-and-style.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# Pre-commit naming + style check for shopdb-flask. +# Enforces CONTRIBUTING.md rules: +# 1. No non-ASCII chars in source (em-dashes, smart quotes, arrows, emojis) +# 2. No banned shorthand identifiers (cfg, ctx, mgr, req, res, env, util, helper) +# as standalone names (suffix usage like printers_bp, request_obj is allowed) +# 3. No snake_case DB column names in __tablename__ or db.Column attrs +# 4. No snake_case API params in frontend that should match DB column names +# +# Exits non-zero if any violation found. +# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/ + +set -e + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" +cd "$REPO_ROOT" + +VIOLATIONS=0 + +EXCLUDES=( + --exclude-dir=venv + --exclude-dir=node_modules + --exclude-dir=__pycache__ + --exclude-dir=dist + --exclude-dir=.git + --exclude-dir=versions +) + +INCLUDES_CODE=( + --include='*.py' + --include='*.vue' + --include='*.js' + --include='*.ts' +) + +INCLUDES_ALL=( + --include='*.py' + --include='*.vue' + --include='*.js' + --include='*.ts' + --include='*.json' + --include='*.md' + --include='*.yaml' + --include='*.yml' +) + +echo "==> Checking for non-ASCII characters..." +NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_CODE[@]}" . 2>/dev/null || true) +if [ -n "$NON_ASCII" ]; then + echo "FAIL: non-ASCII characters found (em-dashes, smart quotes, arrows, emojis):" + echo "$NON_ASCII" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for banned shorthand (standalone)..." +# Match the word as a standalone identifier: not preceded or followed by underscore/word char +# Word boundary in grep is \b but we want to exclude suffix usage like printers_bp +# So: match (^|[^a-zA-Z0-9_])(banned)([^a-zA-Z0-9_]|$) +for word in cfg ctx mgr req res; do + HITS=$(grep -rPn "(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" "${EXCLUDES[@]}" --include='*.py' --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null \ + | grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \ + || true) + if [ -n "$HITS" ]; then + echo "FAIL: banned shorthand '$word' (standalone) found:" + echo "$HITS" + echo + VIOLATIONS=$((VIOLATIONS + 1)) + fi +done + +echo "==> Checking for snake_case DB tablenames..." +SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true) +if [ -n "$SNAKE_TABLES" ]; then + echo "FAIL: snake_case __tablename__ found (must be lowercase concatenated):" + echo "$SNAKE_TABLES" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for snake_case DB column attrs..." +SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true) +if [ -n "$SNAKE_COLS" ]; then + echo "FAIL: snake_case db.Column attribute found (must match column name, no underscores):" + echo "$SNAKE_COLS" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for snake_case ForeignKey targets..." +SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true) +if [ -n "$SNAKE_FK" ]; then + echo "FAIL: snake_case ForeignKey target found:" + echo "$SNAKE_FK" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +echo "==> Checking for snake_case API params in frontend (DB-mirrored fields)..." +SNAKE_FE=$(grep -rPn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" "${EXCLUDES[@]}" --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null || true) +if [ -n "$SNAKE_FE" ]; then + echo "FAIL: snake_case API params in frontend (must match DB column names without underscores):" + echo "$SNAKE_FE" + echo + VIOLATIONS=$((VIOLATIONS + 1)) +fi + +if [ "$VIOLATIONS" -gt 0 ]; then + echo "==================================================" + echo "$VIOLATIONS naming/style violation(s) found." + echo "See CONTRIBUTING.md for the full convention." + echo "==================================================" + exit 1 +fi + +echo "==> All naming/style checks passed." +exit 0 diff --git a/shopdb/core/models/auditlog.py b/shopdb/core/models/auditlog.py index ef93221..c18e056 100644 --- a/shopdb/core/models/auditlog.py +++ b/shopdb/core/models/auditlog.py @@ -108,16 +108,16 @@ class AuditLog(db.Model): username = user.username if user else None # Get request info - req = request or flask_request + request_obj = request or flask_request ipaddress = None useragent = None - if req: + if request_obj: # Handle proxy forwarding - ipaddress = req.headers.get('X-Forwarded-For', req.remote_addr) + ipaddress = request_obj.headers.get('X-Forwarded-For', request_obj.remote_addr) if ipaddress and ',' in ipaddress: ipaddress = ipaddress.split(',')[0].strip() - useragent = req.headers.get('User-Agent', '')[:255] + useragent = request_obj.headers.get('User-Agent', '')[:255] entry = cls( userid=userid, diff --git a/shopdb/utils/pagination.py b/shopdb/utils/pagination.py index 4e5ef3b..09bdf95 100644 --- a/shopdb/utils/pagination.py +++ b/shopdb/utils/pagination.py @@ -4,26 +4,26 @@ from flask import request, current_app from typing import Tuple -def get_pagination_params(req=None) -> Tuple[int, int]: +def get_pagination_params(request_obj=None) -> Tuple[int, int]: """ Extract pagination parameters from request. Returns: Tuple of (page, per_page) """ - if req is None: - req = request + if request_obj is None: + request_obj = request default_size = current_app.config.get('DEFAULT_PAGE_SIZE', 20) max_size = current_app.config.get('MAX_PAGE_SIZE', 100) try: - page = max(1, int(req.args.get('page', 1))) + page = max(1, int(request_obj.args.get('page', 1))) except (TypeError, ValueError): page = 1 try: - per_page = int(req.args.get('perpage', req.args.get('per_page', default_size))) + per_page = int(request_obj.args.get('perpage', request_obj.args.get('per_page', default_size))) per_page = max(1, min(per_page, max_size)) except (TypeError, ValueError): per_page = default_size