Phase 5: Alembic baseline, per-site deploy, ADRs to docs/adr

Migration runner ready and a sister site can deploy from a clean
checkout with one .env file.

ADRs relocated (migrations/adr/ -> docs/adr/):
- migrations/ is now Alembic territory, not docs.
- All cross-references updated: CLAUDE.md, docs/PLUGIN-HOOKS.md,
  docs/PLUGIN-QUICKSTART.md.

Alembic initialized (migrations/):
- env.py, script.py.mako, alembic.ini copied from Flask-Migrate
  templates so `flask db migrate` and `flask db upgrade` work without
  a one-time `flask db init` (which would clash with the existing
  migrations/ directory).
- Baseline migration generated via autogenerate, captures all 47
  tables (core models + 6 plugins) as the upgrade target. Ready for
  per-site `flask db upgrade` from an empty schema.

Deploy artifacts:
- Dockerfile: python:3.12-slim base, gunicorn server, non-root user,
  healthcheck against /api/auth/login. Single image bundles all six
  plugins; sites enable via `flask plugin install <name>`.
- docker-compose.yml: MySQL 8 + API container, healthcheck-gated
  startup, env-driven secrets that fail loud on missing values
  (`${SECRET_KEY:?}` form).
- .env.example: full env-var inventory with comments. Calls out
  required vs optional. Matches what ProductionConfig.validate
  enforces.

docs/DEPLOY.md:
- Step-by-step per-site runbook: clone, configure .env, bring up
  stack, run migrations, seed reference data, install plugins,
  create admin, front with TLS, backups, updates.
- Common-issues table.
- Cross-links to ADR-004 (per-site rationale), ADR-003 (plugin
  distribution), and the config source.

Skills:
- migrating-asset-schema: Alembic + one-shot data migration policy.
  Rules: additive first, renames are three steps, destructive ops
  need rollback, equipment migration filter per ADR-001 + ADR-005.
- hardening-flask-config: production validation, CORS allowlist
  policy, JWT cookie hardening, per-site deploy isolation per ADR-004.

CLAUDE.md updated to reflect the post-Phase-5 state. No tests added
this commit; the Alembic baseline is exercised by the existing
db.create_all-based test suite (tests do not touch the migration
runner; that's by design until per-plugin migrations land).

Test count unchanged: 101 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-08 17:56:19 -04:00
parent 8eb9362452
commit d4e3ac9fc8
19 changed files with 1503 additions and 41 deletions

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

View File

@@ -1,173 +0,0 @@
# 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)

View File

@@ -1,60 +0,0 @@
# 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/<name>/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)

View File

@@ -1,68 +0,0 @@
# 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/<name>/`. 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/<name>/` (clone, submodule, or symlink) and runs `flask plugin install <name>`. 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)

View File

@@ -1,69 +0,0 @@
# 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)

View File

@@ -1,149 +0,0 @@
# 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)

View File

@@ -1,131 +0,0 @@
# 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/<pluginname>` for each plugin returning a schema. Auth is API-key, separate from JWT. Per-plugin keys via env vars:
- `COLLECTOR_API_KEY_<PLUGINNAME>` (preferred, plugin-specific)
- `COLLECTOR_API_KEY` (fallback, shared)
### 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

View File

@@ -1,25 +0,0 @@
# 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.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,956 @@
"""baseline schema
Revision ID: 68b3947ae14f
Revises:
Create Date: 2026-05-08 17:53:08.342776
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '68b3947ae14f'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('appowners',
sa.Column('appownerid', sa.Integer(), nullable=False),
sa.Column('appowner', sa.String(length=100), nullable=False),
sa.Column('sso', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('appownerid')
)
op.create_table('assetstatuses',
sa.Column('statusid', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('statusid'),
sa.UniqueConstraint('status')
)
op.create_table('assettypes',
sa.Column('assettypeid', sa.Integer(), nullable=False),
sa.Column('assettype', sa.String(length=50), nullable=False, comment='Category name: equipment, computer, network_device, printer'),
sa.Column('pluginname', sa.String(length=100), nullable=True, comment='Plugin that owns this type'),
sa.Column('tablename', sa.String(length=100), nullable=True, comment='Extension table name for this type'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('assettypeid'),
sa.UniqueConstraint('assettype')
)
op.create_table('businessunits',
sa.Column('businessunitid', sa.Integer(), nullable=False),
sa.Column('businessunit', sa.String(length=100), nullable=False),
sa.Column('code', sa.String(length=20), nullable=True, comment='Short code'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('parentid', sa.Integer(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['parentid'], ['businessunits.businessunitid'], ),
sa.PrimaryKeyConstraint('businessunitid'),
sa.UniqueConstraint('businessunit'),
sa.UniqueConstraint('code')
)
op.create_table('communicationtypes',
sa.Column('comtypeid', sa.Integer(), nullable=False),
sa.Column('comtype', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('comtypeid'),
sa.UniqueConstraint('comtype')
)
op.create_table('computertypes',
sa.Column('computertypeid', sa.Integer(), nullable=False),
sa.Column('computertype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('computertypeid'),
sa.UniqueConstraint('computertype')
)
op.create_table('equipmenttypes',
sa.Column('equipmenttypeid', sa.Integer(), nullable=False),
sa.Column('equipmenttype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('equipmenttypeid'),
sa.UniqueConstraint('equipmenttype')
)
op.create_table('locations',
sa.Column('locationid', sa.Integer(), nullable=False),
sa.Column('locationname', sa.String(length=100), nullable=False),
sa.Column('building', sa.String(length=100), nullable=True),
sa.Column('floor', sa.String(length=50), nullable=True),
sa.Column('room', sa.String(length=50), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('mapimage', sa.String(length=500), nullable=True, comment='Path to floor map image'),
sa.Column('mapwidth', sa.Integer(), nullable=True),
sa.Column('mapheight', sa.Integer(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('locationid'),
sa.UniqueConstraint('locationname')
)
op.create_table('machinestatuses',
sa.Column('statusid', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('statusid'),
sa.UniqueConstraint('status')
)
op.create_table('machinetypes',
sa.Column('machinetypeid', sa.Integer(), nullable=False),
sa.Column('machinetype', sa.String(length=100), nullable=False),
sa.Column('category', sa.String(length=50), nullable=False, comment='Equipment, PC, Network, or Printer'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('machinetypeid'),
sa.UniqueConstraint('machinetype')
)
op.create_table('networkdevicetypes',
sa.Column('networkdevicetypeid', sa.Integer(), nullable=False),
sa.Column('networkdevicetype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('networkdevicetypeid'),
sa.UniqueConstraint('networkdevicetype')
)
op.create_table('notificationtypes',
sa.Column('notificationtypeid', sa.Integer(), nullable=False),
sa.Column('typename', sa.String(length=50), nullable=False),
sa.Column('typedescription', sa.Text(), nullable=True),
sa.Column('typecolor', sa.String(length=20), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('notificationtypeid')
)
op.create_table('operatingsystems',
sa.Column('osid', sa.Integer(), nullable=False),
sa.Column('osname', sa.String(length=100), nullable=False),
sa.Column('osversion', sa.String(length=50), nullable=True),
sa.Column('architecture', sa.String(length=20), nullable=True, comment='x86, x64, ARM'),
sa.Column('endoflife', sa.Date(), nullable=True, comment='End of support date'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('osid'),
sa.UniqueConstraint('osname', 'osversion', name='uq_os_name_version')
)
op.create_table('pctypes',
sa.Column('pctypeid', sa.Integer(), nullable=False),
sa.Column('pctype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('pctypeid'),
sa.UniqueConstraint('pctype')
)
op.create_table('permissions',
sa.Column('permissionid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('category', sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint('permissionid'),
sa.UniqueConstraint('name')
)
op.create_table('printertypes',
sa.Column('printertypeid', sa.Integer(), nullable=False),
sa.Column('printertype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('printertypeid'),
sa.UniqueConstraint('printertype')
)
op.create_table('relationshiptypes',
sa.Column('relationshiptypeid', sa.Integer(), nullable=False),
sa.Column('relationshiptype', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('relationshiptypeid'),
sa.UniqueConstraint('relationshiptype')
)
op.create_table('roles',
sa.Column('roleid', sa.Integer(), nullable=False),
sa.Column('rolename', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('roleid'),
sa.UniqueConstraint('rolename')
)
op.create_table('settings',
sa.Column('settingid', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('value', sa.Text(), nullable=True),
sa.Column('valuetype', sa.String(length=20), nullable=True),
sa.Column('category', sa.String(length=50), nullable=True),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=True),
sa.Column('modifieddate', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('settingid')
)
with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_settings_key'), ['key'], unique=True)
op.create_table('usbdevicetypes',
sa.Column('usbdevicetypeid', sa.Integer(), nullable=False),
sa.Column('typename', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('usbdevicetypeid'),
sa.UniqueConstraint('typename')
)
op.create_table('users',
sa.Column('userid', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('passwordhash', sa.String(length=255), nullable=False),
sa.Column('firstname', sa.String(length=100), nullable=True),
sa.Column('lastname', sa.String(length=100), nullable=True),
sa.Column('lastlogindate', sa.DateTime(), nullable=True),
sa.Column('failedlogins', sa.Integer(), nullable=True),
sa.Column('lockeduntil', sa.DateTime(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('userid'),
sa.UniqueConstraint('email')
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True)
op.create_table('vendors',
sa.Column('vendorid', sa.Integer(), nullable=False),
sa.Column('vendor', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('website', sa.String(length=255), nullable=True),
sa.Column('supportphone', sa.String(length=50), nullable=True),
sa.Column('supportemail', sa.String(length=100), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('vendorid'),
sa.UniqueConstraint('vendor')
)
op.create_table('vlans',
sa.Column('vlanid', sa.Integer(), nullable=False),
sa.Column('vlannumber', sa.Integer(), nullable=False, comment='VLAN ID number'),
sa.Column('name', sa.String(length=100), nullable=False, comment='VLAN name'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('vlantype', sa.String(length=50), nullable=True, comment='Type: data, voice, management, guest, etc.'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('vlanid'),
sa.UniqueConstraint('vlannumber')
)
with op.batch_alter_table('vlans', schema=None) as batch_op:
batch_op.create_index('idx_vlan_number', ['vlannumber'], unique=False)
op.create_table('assets',
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('assetnumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'),
sa.Column('name', sa.String(length=100), nullable=True, comment='Display name/alias'),
sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'),
sa.Column('assettypeid', sa.Integer(), nullable=False),
sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'),
sa.Column('locationid', sa.Integer(), nullable=True),
sa.Column('businessunitid', sa.Integer(), nullable=True),
sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'),
sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('deleteddate', sa.DateTime(), nullable=True),
sa.Column('deletedby', sa.String(length=100), nullable=True),
sa.Column('createdby', sa.String(length=100), nullable=True),
sa.Column('modifiedby', sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(['assettypeid'], ['assettypes.assettypeid'], ),
sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ),
sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ),
sa.ForeignKeyConstraint(['statusid'], ['assetstatuses.statusid'], ),
sa.PrimaryKeyConstraint('assetid')
)
with op.batch_alter_table('assets', schema=None) as batch_op:
batch_op.create_index('idx_asset_active', ['isactive'], unique=False)
batch_op.create_index('idx_asset_location', ['locationid'], unique=False)
batch_op.create_index('idx_asset_status', ['statusid'], unique=False)
batch_op.create_index('idx_asset_type_bu', ['assettypeid', 'businessunitid'], unique=False)
batch_op.create_index(batch_op.f('ix_assets_assetnumber'), ['assetnumber'], unique=True)
batch_op.create_index(batch_op.f('ix_assets_serialnumber'), ['serialnumber'], unique=False)
op.create_table('auditlogs',
sa.Column('auditlogid', sa.Integer(), nullable=False),
sa.Column('userid', sa.Integer(), nullable=True),
sa.Column('username', sa.String(length=100), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('ipaddress', sa.String(length=45), nullable=True),
sa.Column('useragent', sa.String(length=255), nullable=True),
sa.Column('action', sa.String(length=20), nullable=False),
sa.Column('entitytype', sa.String(length=50), nullable=False),
sa.Column('entityid', sa.Integer(), nullable=True),
sa.Column('entityname', sa.String(length=255), nullable=True),
sa.Column('changes', sa.Text(), nullable=True),
sa.Column('details', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['userid'], ['users.userid'], ),
sa.PrimaryKeyConstraint('auditlogid')
)
with op.batch_alter_table('auditlogs', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_auditlogs_action'), ['action'], unique=False)
batch_op.create_index(batch_op.f('ix_auditlogs_entitytype'), ['entitytype'], unique=False)
batch_op.create_index(batch_op.f('ix_auditlogs_timestamp'), ['timestamp'], unique=False)
op.create_table('models',
sa.Column('modelnumberid', sa.Integer(), nullable=False),
sa.Column('modelnumber', sa.String(length=100), nullable=False),
sa.Column('machinetypeid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('imageurl', sa.String(length=500), nullable=True, comment='URL to product image'),
sa.Column('documentationurl', sa.String(length=500), nullable=True, comment='URL to documentation'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('modelnumberid'),
sa.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor')
)
op.create_table('notifications',
sa.Column('notificationid', sa.Integer(), nullable=False),
sa.Column('notificationtypeid', sa.Integer(), nullable=True),
sa.Column('businessunitid', sa.Integer(), nullable=True),
sa.Column('appid', sa.Integer(), nullable=True),
sa.Column('notification', sa.Text(), nullable=False, comment='The message content'),
sa.Column('starttime', sa.DateTime(), nullable=True),
sa.Column('endtime', sa.DateTime(), nullable=True),
sa.Column('ticketnumber', sa.String(length=50), nullable=True),
sa.Column('link', sa.String(length=500), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.Column('isshopfloor', sa.Boolean(), nullable=True),
sa.Column('employeesso', sa.String(length=100), nullable=True),
sa.Column('employeename', sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(['notificationtypeid'], ['notificationtypes.notificationtypeid'], ),
sa.PrimaryKeyConstraint('notificationid')
)
op.create_table('rolepermissions',
sa.Column('roleid', sa.Integer(), nullable=False),
sa.Column('permissionid', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['permissionid'], ['permissions.permissionid'], ),
sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ),
sa.PrimaryKeyConstraint('roleid', 'permissionid')
)
op.create_table('subnets',
sa.Column('subnetid', sa.Integer(), nullable=False),
sa.Column('cidr', sa.String(length=18), nullable=False, comment='CIDR notation (e.g., 10.1.1.0/24)'),
sa.Column('name', sa.String(length=100), nullable=False, comment='Subnet name'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('gatewayip', sa.String(length=15), nullable=True, comment='Default gateway IP address'),
sa.Column('subnetmask', sa.String(length=15), nullable=True, comment='Subnet mask (e.g., 255.255.255.0)'),
sa.Column('networkaddress', sa.String(length=15), nullable=True, comment='Network address (e.g., 10.1.1.0)'),
sa.Column('broadcastaddress', sa.String(length=15), nullable=True, comment='Broadcast address (e.g., 10.1.1.255)'),
sa.Column('vlanid', sa.Integer(), nullable=True),
sa.Column('subnettype', sa.String(length=50), nullable=True, comment='Type: production, development, management, dmz, etc.'),
sa.Column('locationid', sa.Integer(), nullable=True),
sa.Column('dhcpenabled', sa.Boolean(), nullable=True, comment='DHCP enabled for this subnet'),
sa.Column('dhcprangestart', sa.String(length=15), nullable=True, comment='DHCP range start IP'),
sa.Column('dhcprangeend', sa.String(length=15), nullable=True, comment='DHCP range end IP'),
sa.Column('dns1', sa.String(length=15), nullable=True, comment='Primary DNS server'),
sa.Column('dns2', sa.String(length=15), nullable=True, comment='Secondary DNS server'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ),
sa.ForeignKeyConstraint(['vlanid'], ['vlans.vlanid'], ),
sa.PrimaryKeyConstraint('subnetid'),
sa.UniqueConstraint('cidr')
)
with op.batch_alter_table('subnets', schema=None) as batch_op:
batch_op.create_index('idx_subnet_cidr', ['cidr'], unique=False)
batch_op.create_index('idx_subnet_location', ['locationid'], unique=False)
batch_op.create_index('idx_subnet_vlan', ['vlanid'], unique=False)
op.create_table('supportteams',
sa.Column('supportteamid', sa.Integer(), nullable=False),
sa.Column('teamname', sa.String(length=100), nullable=False),
sa.Column('teamurl', sa.String(length=255), nullable=True),
sa.Column('appownerid', sa.Integer(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['appownerid'], ['appowners.appownerid'], ),
sa.PrimaryKeyConstraint('supportteamid')
)
op.create_table('usbdevices',
sa.Column('usbdeviceid', sa.Integer(), nullable=False),
sa.Column('serialnumber', sa.String(length=100), nullable=False),
sa.Column('label', sa.String(length=100), nullable=True, comment='Human-readable label'),
sa.Column('assetnumber', sa.String(length=50), nullable=True, comment='Optional asset tag'),
sa.Column('usbdevicetypeid', sa.Integer(), nullable=True),
sa.Column('capacitygb', sa.Integer(), nullable=True, comment='Capacity in GB'),
sa.Column('vendorid', sa.String(length=10), nullable=True, comment='USB Vendor ID (hex)'),
sa.Column('productid', sa.String(length=10), nullable=True, comment='USB Product ID (hex)'),
sa.Column('manufacturer', sa.String(length=100), nullable=True),
sa.Column('productname', sa.String(length=100), nullable=True),
sa.Column('ischeckedout', sa.Boolean(), nullable=True),
sa.Column('currentuserid', sa.String(length=50), nullable=True, comment='SSO of current user'),
sa.Column('currentusername', sa.String(length=100), nullable=True, comment='Name of current user'),
sa.Column('currentcheckoutdate', sa.DateTime(), nullable=True),
sa.Column('storagelocation', sa.String(length=200), nullable=True, comment='Where device is stored when not checked out'),
sa.Column('pin', sa.String(length=50), nullable=True, comment='PIN for encrypted devices'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('createdby', sa.String(length=100), nullable=True),
sa.Column('modifiedby', sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(['usbdevicetypeid'], ['usbdevicetypes.usbdevicetypeid'], ),
sa.PrimaryKeyConstraint('usbdeviceid'),
sa.UniqueConstraint('serialnumber')
)
with op.batch_alter_table('usbdevices', schema=None) as batch_op:
batch_op.create_index('idx_usb_checkedout', ['ischeckedout'], unique=False)
batch_op.create_index('idx_usb_currentuser', ['currentuserid'], unique=False)
batch_op.create_index('idx_usb_serial', ['serialnumber'], unique=False)
batch_op.create_index('idx_usb_type', ['usbdevicetypeid'], unique=False)
op.create_table('userroles',
sa.Column('userid', sa.Integer(), nullable=False),
sa.Column('roleid', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ),
sa.ForeignKeyConstraint(['userid'], ['users.userid'], ),
sa.PrimaryKeyConstraint('userid', 'roleid')
)
op.create_table('applications',
sa.Column('appid', sa.Integer(), nullable=False),
sa.Column('appname', sa.String(length=100), nullable=False),
sa.Column('appdescription', sa.String(length=255), nullable=True),
sa.Column('supportteamid', sa.Integer(), nullable=True),
sa.Column('isinstallable', sa.Boolean(), nullable=True),
sa.Column('applicationnotes', sa.Text(), nullable=True),
sa.Column('installpath', sa.String(length=255), nullable=True),
sa.Column('applicationlink', sa.String(length=512), nullable=True),
sa.Column('documentationpath', sa.String(length=512), nullable=True),
sa.Column('ishidden', sa.Boolean(), nullable=True),
sa.Column('isprinter', sa.Boolean(), nullable=True),
sa.Column('islicenced', sa.Boolean(), nullable=True),
sa.Column('image', sa.String(length=255), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['supportteamid'], ['supportteams.supportteamid'], ),
sa.PrimaryKeyConstraint('appid'),
sa.UniqueConstraint('appname')
)
op.create_table('assetrelationships',
sa.Column('relationshipid', sa.Integer(), nullable=False),
sa.Column('sourceassetid', sa.Integer(), nullable=False),
sa.Column('targetassetid', sa.Integer(), nullable=False),
sa.Column('relationshiptypeid', sa.Integer(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ),
sa.ForeignKeyConstraint(['sourceassetid'], ['assets.assetid'], ),
sa.ForeignKeyConstraint(['targetassetid'], ['assets.assetid'], ),
sa.PrimaryKeyConstraint('relationshipid'),
sa.UniqueConstraint('sourceassetid', 'targetassetid', 'relationshiptypeid', name='uq_asset_relationship')
)
with op.batch_alter_table('assetrelationships', schema=None) as batch_op:
batch_op.create_index('idx_asset_rel_source', ['sourceassetid'], unique=False)
batch_op.create_index('idx_asset_rel_target', ['targetassetid'], unique=False)
op.create_table('computers',
sa.Column('computerid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('computertypeid', sa.Integer(), nullable=True),
sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'),
sa.Column('osid', sa.Integer(), nullable=True),
sa.Column('loggedinuser', sa.String(length=100), nullable=True),
sa.Column('lastreporteddate', sa.DateTime(), nullable=True),
sa.Column('lastboottime', sa.DateTime(), nullable=True),
sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'),
sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'),
sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC (vs office PC)'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['computertypeid'], ['computertypes.computertypeid'], ),
sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ),
sa.PrimaryKeyConstraint('computerid')
)
with op.batch_alter_table('computers', schema=None) as batch_op:
batch_op.create_index('idx_computer_hostname', ['hostname'], unique=False)
batch_op.create_index('idx_computer_os', ['osid'], unique=False)
batch_op.create_index('idx_computer_type', ['computertypeid'], unique=False)
batch_op.create_index(batch_op.f('ix_computers_assetid'), ['assetid'], unique=True)
batch_op.create_index(batch_op.f('ix_computers_hostname'), ['hostname'], unique=False)
op.create_table('equipment',
sa.Column('equipmentid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('equipmenttypeid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('modelnumberid', sa.Integer(), nullable=True),
sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'),
sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual equipment)'),
sa.Column('lastmaintenancedate', sa.DateTime(), nullable=True),
sa.Column('nextmaintenancedate', sa.DateTime(), nullable=True),
sa.Column('maintenanceintervaldays', sa.Integer(), nullable=True),
sa.Column('controllervendorid', sa.Integer(), nullable=True, comment='Controller vendor (e.g., FANUC)'),
sa.Column('controllermodelid', sa.Integer(), nullable=True, comment='Controller model (e.g., 31B)'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['controllermodelid'], ['models.modelnumberid'], ),
sa.ForeignKeyConstraint(['controllervendorid'], ['vendors.vendorid'], ),
sa.ForeignKeyConstraint(['equipmenttypeid'], ['equipmenttypes.equipmenttypeid'], ),
sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('equipmentid')
)
with op.batch_alter_table('equipment', schema=None) as batch_op:
batch_op.create_index('idx_equipment_type', ['equipmenttypeid'], unique=False)
batch_op.create_index('idx_equipment_vendor', ['vendorid'], unique=False)
batch_op.create_index(batch_op.f('ix_equipment_assetid'), ['assetid'], unique=True)
op.create_table('machines',
sa.Column('machineid', sa.Integer(), nullable=False),
sa.Column('machinenumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'),
sa.Column('alias', sa.String(length=100), nullable=True, comment='Friendly name'),
sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname (for PCs)'),
sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'),
sa.Column('machinetypeid', sa.Integer(), nullable=False),
sa.Column('pctypeid', sa.Integer(), nullable=True, comment='Set for PCs, NULL for equipment'),
sa.Column('businessunitid', sa.Integer(), nullable=True),
sa.Column('modelnumberid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'),
sa.Column('locationid', sa.Integer(), nullable=True),
sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'),
sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'),
sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual machine)'),
sa.Column('osid', sa.Integer(), nullable=True),
sa.Column('loggedinuser', sa.String(length=100), nullable=True),
sa.Column('lastreporteddate', sa.DateTime(), nullable=True),
sa.Column('lastboottime', sa.DateTime(), nullable=True),
sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'),
sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'),
sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC'),
sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('deleteddate', sa.DateTime(), nullable=True),
sa.Column('deletedby', sa.String(length=100), nullable=True),
sa.Column('createdby', sa.String(length=100), nullable=True),
sa.Column('modifiedby', sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ),
sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ),
sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ),
sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ),
sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ),
sa.ForeignKeyConstraint(['pctypeid'], ['pctypes.pctypeid'], ),
sa.ForeignKeyConstraint(['statusid'], ['machinestatuses.statusid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('machineid')
)
with op.batch_alter_table('machines', schema=None) as batch_op:
batch_op.create_index('idx_machine_active', ['isactive'], unique=False)
batch_op.create_index('idx_machine_hostname', ['hostname'], unique=False)
batch_op.create_index('idx_machine_location', ['locationid'], unique=False)
batch_op.create_index('idx_machine_type_bu', ['machinetypeid', 'businessunitid'], unique=False)
batch_op.create_index(batch_op.f('ix_machines_hostname'), ['hostname'], unique=False)
batch_op.create_index(batch_op.f('ix_machines_machinenumber'), ['machinenumber'], unique=True)
batch_op.create_index(batch_op.f('ix_machines_serialnumber'), ['serialnumber'], unique=False)
op.create_table('networkdevices',
sa.Column('networkdeviceid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('networkdevicetypeid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'),
sa.Column('firmwareversion', sa.String(length=100), nullable=True),
sa.Column('portcount', sa.Integer(), nullable=True, comment='Number of ports (for switches)'),
sa.Column('ispoe', sa.Boolean(), nullable=True, comment='Power over Ethernet capable'),
sa.Column('ismanaged', sa.Boolean(), nullable=True, comment='Managed device (SNMP, web interface, etc.)'),
sa.Column('rackunit', sa.String(length=20), nullable=True, comment='Rack unit position (e.g., U1, U5)'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['networkdevicetypeid'], ['networkdevicetypes.networkdevicetypeid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('networkdeviceid')
)
with op.batch_alter_table('networkdevices', schema=None) as batch_op:
batch_op.create_index('idx_netdev_hostname', ['hostname'], unique=False)
batch_op.create_index('idx_netdev_type', ['networkdevicetypeid'], unique=False)
batch_op.create_index('idx_netdev_vendor', ['vendorid'], unique=False)
batch_op.create_index(batch_op.f('ix_networkdevices_assetid'), ['assetid'], unique=True)
batch_op.create_index(batch_op.f('ix_networkdevices_hostname'), ['hostname'], unique=False)
op.create_table('printers',
sa.Column('printerid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('printertypeid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('modelnumberid', sa.Integer(), nullable=True),
sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'),
sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'),
sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'),
sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'),
sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'),
sa.Column('pin', sa.String(length=20), nullable=True),
sa.Column('iscolor', sa.Boolean(), nullable=True, comment='Color capable'),
sa.Column('isduplex', sa.Boolean(), nullable=True, comment='Duplex capable'),
sa.Column('isnetwork', sa.Boolean(), nullable=True, comment='Network connected'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ),
sa.ForeignKeyConstraint(['printertypeid'], ['printertypes.printertypeid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('printerid')
)
with op.batch_alter_table('printers', schema=None) as batch_op:
batch_op.create_index('idx_printer_hostname', ['hostname'], unique=False)
batch_op.create_index('idx_printer_type', ['printertypeid'], unique=False)
batch_op.create_index('idx_printer_windowsname', ['windowsname'], unique=False)
batch_op.create_index(batch_op.f('ix_printers_assetid'), ['assetid'], unique=True)
batch_op.create_index(batch_op.f('ix_printers_hostname'), ['hostname'], unique=False)
op.create_table('usbcheckouts',
sa.Column('checkoutid', sa.Integer(), nullable=False),
sa.Column('usbdeviceid', sa.Integer(), nullable=True),
sa.Column('machineid', sa.Integer(), nullable=False),
sa.Column('sso', sa.String(length=20), nullable=False, comment='SSO of user'),
sa.Column('checkoutname', sa.String(length=100), nullable=True, comment='Name of user'),
sa.Column('checkouttime', sa.DateTime(), nullable=False),
sa.Column('checkintime', sa.DateTime(), nullable=True),
sa.Column('checkoutreason', sa.Text(), nullable=True, comment='Reason for checkout'),
sa.Column('checkinnotes', sa.Text(), nullable=True),
sa.Column('waswiped', sa.Boolean(), nullable=True, comment='Was device wiped after return'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['usbdeviceid'], ['usbdevices.usbdeviceid'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('checkoutid')
)
op.create_table('appversions',
sa.Column('appversionid', sa.Integer(), nullable=False),
sa.Column('appid', sa.Integer(), nullable=False),
sa.Column('version', sa.String(length=50), nullable=False),
sa.Column('releasedate', sa.Date(), nullable=True),
sa.Column('notes', sa.String(length=255), nullable=True),
sa.Column('dateadded', sa.DateTime(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ),
sa.PrimaryKeyConstraint('appversionid'),
sa.UniqueConstraint('appid', 'version', name='uq_app_version')
)
op.create_table('communications',
sa.Column('communicationid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=True, comment='FK to assets table (new architecture)'),
sa.Column('machineid', sa.Integer(), nullable=True, comment='DEPRECATED: FK to machines table - use assetid instead'),
sa.Column('comtypeid', sa.Integer(), nullable=False),
sa.Column('ipaddress', sa.String(length=50), nullable=True),
sa.Column('subnetmask', sa.String(length=50), nullable=True),
sa.Column('gateway', sa.String(length=50), nullable=True),
sa.Column('dns1', sa.String(length=50), nullable=True),
sa.Column('dns2', sa.String(length=50), nullable=True),
sa.Column('macaddress', sa.String(length=50), nullable=True),
sa.Column('isdhcp', sa.Boolean(), nullable=True),
sa.Column('comport', sa.String(length=20), nullable=True),
sa.Column('baudrate', sa.Integer(), nullable=True),
sa.Column('databits', sa.Integer(), nullable=True),
sa.Column('stopbits', sa.String(length=10), nullable=True),
sa.Column('parity', sa.String(length=20), nullable=True),
sa.Column('flowcontrol', sa.String(length=20), nullable=True),
sa.Column('port', sa.Integer(), nullable=True),
sa.Column('username', sa.String(length=100), nullable=True),
sa.Column('pathname', sa.String(length=255), nullable=True),
sa.Column('pathname2', sa.String(length=255), nullable=True, comment='Secondary path for dualpath'),
sa.Column('isprimary', sa.Boolean(), nullable=True, comment='Primary communication method'),
sa.Column('ismachinenetwork', sa.Boolean(), nullable=True, comment='On machine network vs office network'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ),
sa.ForeignKeyConstraint(['comtypeid'], ['communicationtypes.comtypeid'], ),
sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ),
sa.PrimaryKeyConstraint('communicationid')
)
with op.batch_alter_table('communications', schema=None) as batch_op:
batch_op.create_index('idx_comm_asset', ['assetid'], unique=False)
batch_op.create_index('idx_comm_ip', ['ipaddress'], unique=False)
batch_op.create_index('idx_comm_machine', ['machineid'], unique=False)
batch_op.create_index(batch_op.f('ix_communications_assetid'), ['assetid'], unique=False)
op.create_table('knowledgebase',
sa.Column('linkid', sa.Integer(), nullable=False),
sa.Column('appid', sa.Integer(), nullable=True),
sa.Column('shortdescription', sa.String(length=500), nullable=False),
sa.Column('linkurl', sa.String(length=2000), nullable=True),
sa.Column('keywords', sa.String(length=500), nullable=True),
sa.Column('clicks', sa.Integer(), nullable=True),
sa.Column('lastupdated', sa.DateTime(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ),
sa.PrimaryKeyConstraint('linkid')
)
op.create_table('machinerelationships',
sa.Column('relationshipid', sa.Integer(), nullable=False),
sa.Column('parentmachineid', sa.Integer(), nullable=False),
sa.Column('childmachineid', sa.Integer(), nullable=False),
sa.Column('relationshiptypeid', sa.Integer(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['childmachineid'], ['machines.machineid'], ),
sa.ForeignKeyConstraint(['parentmachineid'], ['machines.machineid'], ),
sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ),
sa.PrimaryKeyConstraint('relationshipid'),
sa.UniqueConstraint('parentmachineid', 'childmachineid', 'relationshiptypeid', name='uq_machine_relationship')
)
op.create_table('printerdata',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('machineid', sa.Integer(), nullable=False),
sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'),
sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'),
sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'),
sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'),
sa.Column('pin', sa.String(length=20), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('printerdata', schema=None) as batch_op:
batch_op.create_index('idx_printerdata_windowsname', ['windowsname'], unique=False)
batch_op.create_index(batch_op.f('ix_printerdata_machineid'), ['machineid'], unique=True)
op.create_table('computerinstalledapps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('computerid', sa.Integer(), nullable=False),
sa.Column('appid', sa.Integer(), nullable=False),
sa.Column('appversionid', sa.Integer(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('installeddate', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ),
sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ),
sa.ForeignKeyConstraint(['computerid'], ['computers.computerid'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('computerid', 'appid', name='uq_computer_app')
)
with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op:
batch_op.create_index('idx_compapp_app', ['appid'], unique=False)
batch_op.create_index('idx_compapp_computer', ['computerid'], unique=False)
op.create_table('installedapps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('machineid', sa.Integer(), nullable=False),
sa.Column('appid', sa.Integer(), nullable=False),
sa.Column('appversionid', sa.Integer(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('installeddate', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ),
sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ),
sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('machineid', 'appid', name='uq_machine_app')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('installedapps')
with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op:
batch_op.drop_index('idx_compapp_computer')
batch_op.drop_index('idx_compapp_app')
op.drop_table('computerinstalledapps')
with op.batch_alter_table('printerdata', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_printerdata_machineid'))
batch_op.drop_index('idx_printerdata_windowsname')
op.drop_table('printerdata')
op.drop_table('machinerelationships')
op.drop_table('knowledgebase')
with op.batch_alter_table('communications', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_communications_assetid'))
batch_op.drop_index('idx_comm_machine')
batch_op.drop_index('idx_comm_ip')
batch_op.drop_index('idx_comm_asset')
op.drop_table('communications')
op.drop_table('appversions')
op.drop_table('usbcheckouts')
with op.batch_alter_table('printers', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_printers_hostname'))
batch_op.drop_index(batch_op.f('ix_printers_assetid'))
batch_op.drop_index('idx_printer_windowsname')
batch_op.drop_index('idx_printer_type')
batch_op.drop_index('idx_printer_hostname')
op.drop_table('printers')
with op.batch_alter_table('networkdevices', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_networkdevices_hostname'))
batch_op.drop_index(batch_op.f('ix_networkdevices_assetid'))
batch_op.drop_index('idx_netdev_vendor')
batch_op.drop_index('idx_netdev_type')
batch_op.drop_index('idx_netdev_hostname')
op.drop_table('networkdevices')
with op.batch_alter_table('machines', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_machines_serialnumber'))
batch_op.drop_index(batch_op.f('ix_machines_machinenumber'))
batch_op.drop_index(batch_op.f('ix_machines_hostname'))
batch_op.drop_index('idx_machine_type_bu')
batch_op.drop_index('idx_machine_location')
batch_op.drop_index('idx_machine_hostname')
batch_op.drop_index('idx_machine_active')
op.drop_table('machines')
with op.batch_alter_table('equipment', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_equipment_assetid'))
batch_op.drop_index('idx_equipment_vendor')
batch_op.drop_index('idx_equipment_type')
op.drop_table('equipment')
with op.batch_alter_table('computers', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_computers_hostname'))
batch_op.drop_index(batch_op.f('ix_computers_assetid'))
batch_op.drop_index('idx_computer_type')
batch_op.drop_index('idx_computer_os')
batch_op.drop_index('idx_computer_hostname')
op.drop_table('computers')
with op.batch_alter_table('assetrelationships', schema=None) as batch_op:
batch_op.drop_index('idx_asset_rel_target')
batch_op.drop_index('idx_asset_rel_source')
op.drop_table('assetrelationships')
op.drop_table('applications')
op.drop_table('userroles')
with op.batch_alter_table('usbdevices', schema=None) as batch_op:
batch_op.drop_index('idx_usb_type')
batch_op.drop_index('idx_usb_serial')
batch_op.drop_index('idx_usb_currentuser')
batch_op.drop_index('idx_usb_checkedout')
op.drop_table('usbdevices')
op.drop_table('supportteams')
with op.batch_alter_table('subnets', schema=None) as batch_op:
batch_op.drop_index('idx_subnet_vlan')
batch_op.drop_index('idx_subnet_location')
batch_op.drop_index('idx_subnet_cidr')
op.drop_table('subnets')
op.drop_table('rolepermissions')
op.drop_table('notifications')
op.drop_table('models')
with op.batch_alter_table('auditlogs', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_auditlogs_timestamp'))
batch_op.drop_index(batch_op.f('ix_auditlogs_entitytype'))
batch_op.drop_index(batch_op.f('ix_auditlogs_action'))
op.drop_table('auditlogs')
with op.batch_alter_table('assets', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_assets_serialnumber'))
batch_op.drop_index(batch_op.f('ix_assets_assetnumber'))
batch_op.drop_index('idx_asset_type_bu')
batch_op.drop_index('idx_asset_status')
batch_op.drop_index('idx_asset_location')
batch_op.drop_index('idx_asset_active')
op.drop_table('assets')
with op.batch_alter_table('vlans', schema=None) as batch_op:
batch_op.drop_index('idx_vlan_number')
op.drop_table('vlans')
op.drop_table('vendors')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_username'))
op.drop_table('users')
op.drop_table('usbdevicetypes')
with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_settings_key'))
op.drop_table('settings')
op.drop_table('roles')
op.drop_table('relationshiptypes')
op.drop_table('printertypes')
op.drop_table('permissions')
op.drop_table('pctypes')
op.drop_table('operatingsystems')
op.drop_table('notificationtypes')
op.drop_table('networkdevicetypes')
op.drop_table('machinetypes')
op.drop_table('machinestatuses')
op.drop_table('locations')
op.drop_table('equipmenttypes')
op.drop_table('computertypes')
op.drop_table('communicationtypes')
op.drop_table('businessunits')
op.drop_table('assettypes')
op.drop_table('assetstatuses')
op.drop_table('appowners')
# ### end Alembic commands ###