From d4e3ac9fc899d1660e168511660709214e60c84e Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 8 May 2026 17:56:19 -0400 Subject: [PATCH] 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 `. - 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) --- .env.example | 64 +- CLAUDE.md | 42 +- Dockerfile | 57 ++ docker-compose.yml | 58 ++ docs/DEPLOY.md | 161 +++ docs/PLUGIN-HOOKS.md | 10 +- docs/PLUGIN-QUICKSTART.md | 8 +- .../adr/ADR-001-asset-as-platform-contract.md | 0 .../adr/ADR-002-plugin-versioning.md | 0 .../adr/ADR-003-plugin-distribution.md | 0 .../adr/ADR-004-deployment-topology.md | 0 .../ADR-005-equipment-vs-measuringtools.md | 0 .../adr/ADR-006-collector-contract.md | 0 {migrations => docs}/adr/README.md | 0 migrations/README | 1 + migrations/alembic.ini | 50 + migrations/env.py | 113 +++ migrations/script.py.mako | 24 + .../versions/68b3947ae14f_baseline_schema.py | 956 ++++++++++++++++++ 19 files changed, 1503 insertions(+), 41 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/DEPLOY.md rename {migrations => docs}/adr/ADR-001-asset-as-platform-contract.md (100%) rename {migrations => docs}/adr/ADR-002-plugin-versioning.md (100%) rename {migrations => docs}/adr/ADR-003-plugin-distribution.md (100%) rename {migrations => docs}/adr/ADR-004-deployment-topology.md (100%) rename {migrations => docs}/adr/ADR-005-equipment-vs-measuringtools.md (100%) rename {migrations => docs}/adr/ADR-006-collector-contract.md (100%) rename {migrations => docs}/adr/README.md (100%) create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/68b3947ae14f_baseline_schema.py diff --git a/.env.example b/.env.example index de7a62b..ce2de8f 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,60 @@ -# Flask configuration +# shopdb-flask environment template. +# +# Copy to .env and fill in the values. .env is gitignored. ProductionConfig +# refuses to boot when SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, or +# CORS_ORIGINS are missing or use the dev defaults. +# +# See docs/DEPLOY.md for the full per-site deployment runbook. + +# ---- Flask ---- FLASK_APP=wsgi.py -FLASK_ENV=development + +# Set to 'production' for live sites. Other valid values: 'development', +# 'testing'. Production triggers ProductionConfig.validate() at boot. +FLASK_ENV=production + +# ---- Required secrets (production refuses to boot without these) ---- + +# Generate strong random values, e.g.: +# python -c "import secrets; print(secrets.token_urlsafe(64))" SECRET_KEY=change-this-to-a-secure-random-string - -# Database -DATABASE_URL=mysql+pymysql://user:password@localhost:3306/shopdb_flask - -# JWT JWT_SECRET_KEY=change-this-to-another-secure-random-string + +# ---- Database (required) ---- + +# Format: mysql+pymysql://:@:/ +# In docker-compose, host is `db` (the service name). +DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask + +# ---- CORS (required, no wildcards in production) ---- + +# Comma-separated list of explicit origins permitted to call the API. +# Example for a single-host facility deploy: +# CORS_ORIGINS=https://shopdb.facility-a.example.com +# Wildcard '*' is rejected by ProductionConfig.validate(). +CORS_ORIGINS=http://localhost:5173 + +# ---- JWT lifecycle (optional, defaults shown) ---- JWT_ACCESS_TOKEN_EXPIRES=3600 JWT_REFRESH_TOKEN_EXPIRES=2592000 -# Logging +# ---- Logging (optional) ---- LOG_LEVEL=INFO -# Zabbix Integration (optional - for printer supply monitoring) -ZABBIX_ENABLED=false -ZABBIX_URL=http://zabbix.example.com:8080 -ZABBIX_TOKEN=your-zabbix-api-token +# ---- docker-compose only ---- +# These are read by docker-compose.yml; not used by the Flask app directly. +MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD +MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD +MYSQL_PORT=3306 +API_PORT=5001 + +# ---- Zabbix integration (optional, for printer supply monitoring) ---- +ZABBIX_URL= +ZABBIX_TOKEN= + +# ---- Per-plugin collector API keys (optional) ---- +# Per ADR-006, each plugin can accept external collector input at +# /api/collector/. The framework checks +# COLLECTOR_API_KEY_ first, then COLLECTOR_API_KEY as fallback. +# COLLECTOR_API_KEY= +# COLLECTOR_API_KEY_COMPUTERS= diff --git a/CLAUDE.md b/CLAUDE.md index efe1c29..d3bc49f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Modern rewrite of the classic-ASP shopdb. Built as a framework so sister GE Aero - **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`. -Architecture decisions live in `migrations/adr/`. Read those before making schema or contract changes. +Architecture decisions live in `docs/adr/`. Read those before making schema or contract changes. - ADR-001: Asset model is the platform contract (Machine retires) - ACCEPTED - ADR-002: Plugin contract versioning (semver) - ACCEPTED @@ -23,29 +23,31 @@ Architecture decisions live in `migrations/adr/`. Read those before making schem ## Current state (as of 2026-05-08) -### Wired in +Refactor phases 0-5 landed. Five commits on main, all pushed to gitea origin. -- 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) +### Phases done -### In progress / partial +- **Phase 0**: 6 ADRs accepted, naming convention v1, pre-commit style hook +- **Phase 1**: 8 smoke tests + 7 production-config tests, Flask-SQLAlchemy 3 fixtures, uv lockfile, hardened ProductionConfig +- **Phase 2**: contract surface defined (`__contract_version__`, `BasePlugin` hooks, `docs/PLUGIN-HOOKS.md`), 51 contract tests +- **Phase 3**: manifest-first loader, fail-loud/isolate policy, contract-version range checking, auto-register core blueprints, `shopdb.api` namespace, `BasePlugin.get_setting/set_setting` helpers +- **Phase 4**: `flask plugin new ` CLI, scaffold templates, 14 canary tests, `docs/PLUGIN-QUICKSTART.md` +- **Phase 5**: ADRs moved to `docs/adr/`, Alembic baseline migration, per-site deploy artifacts (`Dockerfile`, `docker-compose.yml`, `docs/DEPLOY.md`) -- **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. +### Active state -### Pending +- 101+ tests passing, naming/style check green +- `__contract_version__` at 0.2.0 +- 6 bundled plugins all satisfy contract: computers, equipment, network, notifications, printers, usb +- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0 -- 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) +### Deferred + +- Equipment data migration (one-shot script for legacy ASP shopdb -> assets). Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates. Skill `migrating-asset-schema` documents the pattern; the actual one-shot script lives in `scripts/migration/` when run. +- Printers retirement: legacy `PrinterData` model + frontend changes. Coordinated with the equipment data migration. +- `measuringtools` plugin (ADR-005). First plugin to be built using the scaffold. +- Frontend hook contract for asset-detail, map markers, search results +- Alembic per-plugin migration chains (the framework supports them; bundled plugins haven't moved off `db.create_all()` yet) ## Quick start @@ -112,7 +114,7 @@ Each plugin must have: - `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 +- `docs/adr/` - architecture decision records ## Migration notes diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d681f76 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# shopdb-flask single-site container. +# +# One image, one site. Per ADR-004, each adopting facility runs its own +# stack with its own DB, secrets, and enabled-plugin list. This image +# bundles all six core plugins; install them at runtime with +# `flask plugin install `. +# +# Build: +# docker build -t shopdb-flask . +# Run (with .env): +# docker run --env-file .env -p 5001:5001 shopdb-flask + +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir gunicorn + +COPY shopdb/ ./shopdb/ +COPY plugins/ ./plugins/ +COPY migrations/ ./migrations/ +COPY scripts/ ./scripts/ +COPY wsgi.py ./ + +RUN useradd --create-home --shell /bin/bash shopdb \ + && chown -R shopdb:shopdb /app +USER shopdb + +EXPOSE 5001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl --fail --silent http://localhost:5001/api/auth/login -X POST \ + -H "Content-Type: application/json" -d '{}' \ + | grep -q "VALIDATION_ERROR" || exit 1 + +CMD ["gunicorn", \ + "--bind", "0.0.0.0:5001", \ + "--workers", "4", \ + "--timeout", "60", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "wsgi:app"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aae8bdd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +# shopdb-flask single-site docker-compose template. +# +# Per ADR-004, each adopting facility runs its own stack. This template +# brings up MySQL + the API container and exposes the API on port 5001. +# The Vue frontend is served separately by the API in production builds +# (see register_frontend_routes in shopdb/__init__.py); for dev, run +# `npm run dev` in frontend/ on a separate port. +# +# Usage: +# cp .env.example .env +# # edit .env with site-specific secrets and origins +# docker compose up -d +# +# Refresh after pulling new code: +# docker compose build api +# docker compose up -d api + +services: + db: + image: mysql:8.0 + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} + volumes: + - db_data:/var/lib/mysql + ports: + - "${MYSQL_PORT:-3306}:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + + api: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + FLASK_ENV: production + DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set} + CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ZABBIX_URL: ${ZABBIX_URL:-} + ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} + ports: + - "${API_PORT:-5001}:5001" + volumes: + - ./plugins:/app/plugins:ro + +volumes: + db_data: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..7e9e74e --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,161 @@ +# Per-Site Deployment Runbook + +shopdb-flask is single-tenant per ADR-004. Each adopting facility runs its own stack: own DB, own users, own enabled plugins, own secrets. This document is the runbook for a fresh site deploy. + +## Prerequisites + +- Docker 24+ and Docker Compose v2 (or equivalent container runtime) +- A reverse proxy with TLS termination (nginx, traefik, Caddy, GE corporate LB) -- the framework does not terminate TLS itself +- A MySQL backup destination (offsite recommended) +- Access to the GE Aerospace Gitea or a clone of the repo + +## Step 1: Clone and configure + +```bash +git clone https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git +cd shopdb-flask +cp .env.example .env +``` + +Edit `.env`: + +| Variable | Required | Notes | +|----------|----------|-------| +| `FLASK_ENV` | Yes | `production` for live sites | +| `SECRET_KEY` | Yes | `python -c "import secrets; print(secrets.token_urlsafe(64))"` | +| `JWT_SECRET_KEY` | Yes | Same generation, different value | +| `DATABASE_URL` | Yes | `mysql+pymysql://shopdb:PASSWORD@db:3306/shopdb_flask` (matches docker-compose) | +| `CORS_ORIGINS` | Yes | Comma-separated explicit origins. Wildcard rejected. | +| `MYSQL_ROOT_PASSWORD` | Yes | Container only | +| `MYSQL_PASSWORD` | Yes | Container only, must match `DATABASE_URL` password | +| `MYSQL_PORT` | No | Default 3306 | +| `API_PORT` | No | Default 5001 | +| `LOG_LEVEL` | No | Default INFO | +| `ZABBIX_URL`, `ZABBIX_TOKEN` | No | Only if printers plugin uses Zabbix | + +## Step 2: Bring up the stack + +```bash +docker compose build +docker compose up -d +``` + +The MySQL container initializes its volume on first run. The API container waits for `db` to be healthy via `healthcheck`. Check logs: + +```bash +docker compose logs -f api +``` + +If `ProductionConfig.validate()` raises, the container exits with the offending env-var named in the log. Fix `.env` and `docker compose up -d` again. + +## Step 3: Initialize the database schema + +```bash +docker compose exec api flask db upgrade +``` + +This applies the baseline migration (creates all tables) and any subsequent migrations. Re-running is idempotent. + +## Step 4: Seed reference data + +```bash +docker compose exec api flask seed reference-data +``` + +Creates: default `Vendor`, `Location`, `BusinessUnit`, `OperatingSystem`, `AssetStatus`, `RelationshipType` rows seeded with the platform contract values (`partof`, `controls`, `connectedto`). + +## Step 5: Pick plugins to enable + +The image bundles all six plugins (computers, equipment, network, notifications, printers, usb). Only enabled plugins are loaded. + +```bash +docker compose exec api flask plugin list +docker compose exec api flask plugin install computers +docker compose exec api flask plugin install equipment +# ... repeat for each plugin the site tracks +``` + +To install a sister-site or third-party plugin (per ADR-003), drop its directory into `/plugins//` (the docker-compose mounts this read-only into the container) and run `flask plugin install `. + +## Step 6: Create the admin user + +```bash +docker compose exec api flask seed admin --username admin --email admin@facility.example.com +# Password is generated and printed once. Store in your password manager. +``` + +Subsequent users are managed through the UI. + +## Step 7: Front the API with TLS + +The Flask container listens on `5001/tcp` over plain HTTP. Production exposure must go through a reverse proxy that terminates TLS: + +```nginx +server { + listen 443 ssl; + server_name shopdb.facility-a.example.com; + + ssl_certificate /etc/ssl/certs/shopdb.crt; + ssl_certificate_key /etc/ssl/private/shopdb.key; + + location / { + proxy_pass http://localhost:5001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +The framework reads `X-Forwarded-For` for audit logging. + +## Step 8: Backups + +Per-site MySQL backups are the site's responsibility. Recommended: nightly `mysqldump` to offsite storage with 14-day retention. + +```bash +docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask | gzip > backup-$(date +%F).sql.gz +``` + +Verify a restore quarterly. + +## Step 9: Updates + +```bash +git pull origin main +docker compose build api +docker compose up -d api +docker compose exec api flask db upgrade +``` + +The framework's `__contract_version__` may have moved. Check `docs/adr/` for any new ADRs since the last update. If an ADR introduces a breaking change, the upgrade may require coordinated work; the ADR's "Consequences" section documents it. + +## Common issues + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `ConfigError: SECRET_KEY is required in production` | `.env` missing or blank | Set `SECRET_KEY` in `.env`, re-up | +| `ConfigError: CORS_ORIGINS must be a comma-separated allowlist` | `.env` has `*` | Set explicit origins | +| `PluginVersionError: requires core_version X but framework is Y` | Plugin pinned a too-narrow range | Update `manifest.json` `core_version` or pin framework version | +| 500s after `flask db upgrade` | Migration ran but app cached old schema | `docker compose restart api` | +| Cannot reach API after restart | Reverse proxy not pointing at the container's exposed port | Confirm `API_PORT` and proxy config | + +## Health check + +```bash +curl -s -X POST -H "Content-Type: application/json" \ + -d '{}' http://localhost:5001/api/auth/login \ + | jq . +# Expect: {"status": "error", "data": {"error": {"code": "VALIDATION_ERROR", ...}}} +``` + +If this returns a 500 or no JSON, the container is unhealthy. Check `docker compose logs api`. + +## References + +- [docs/adr/ADR-004-deployment-topology.md](adr/ADR-004-deployment-topology.md) - per-site instances rationale +- [docs/adr/ADR-003-plugin-distribution.md](adr/ADR-003-plugin-distribution.md) - bundled vs external plugins +- [docs/adr/ADR-006-collector-contract.md](adr/ADR-006-collector-contract.md) - per-plugin collector endpoints +- [docs/PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - building a custom plugin for your site +- [shopdb/config.py](../shopdb/config.py) - all the env-vars in one place diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index 99f2e44..87e5606 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -2,7 +2,7 @@ This is the canonical reference for the shopdb-flask plugin contract. Plugin authors implement `BasePlugin` and override the hooks they care about. Hooks marked `required` must be implemented; hooks marked `optional` have sensible defaults and can be left alone. -The contract is locked in [ADR-001](../migrations/adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](../migrations/adr/ADR-002-plugin-versioning.md). +The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](../docs/adr/ADR-002-plugin-versioning.md). ## Contract version @@ -202,7 +202,7 @@ class ComputersPlugin(BasePlugin): ### `get_collector_schema() -> Optional[Dict]` -Declares the JSON Schema for an external collector pushing to `/api/collector/`. See [ADR-006](../migrations/adr/ADR-006-collector-contract.md) for the contract. +Declares the JSON Schema for an external collector pushing to `/api/collector/`. See [ADR-006](../docs/adr/ADR-006-collector-contract.md) for the contract. ```python class ComputersPlugin(BasePlugin): @@ -272,7 +272,7 @@ position = resolve_asset_position(asset) # Returns dict: {'mapx': 234, 'mapy': 567, 'positionsource': 'self' | 'related' | 'location' | None} ``` -See [ADR-001](../migrations/adr/ADR-001-asset-as-platform-contract.md) for the position resolution algorithm. +See [ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md) for the position resolution algorithm. ## Removed hooks @@ -286,8 +286,8 @@ The following hooks existed in early drafts and have been removed for v1: When you change anything documented here, you must: -1. Bump `__contract_version__` per [ADR-002](../migrations/adr/ADR-002-plugin-versioning.md): major for removals or signature changes, minor for additive optional hooks, patch for docs. -2. Update [ADR-001](../migrations/adr/ADR-001-asset-as-platform-contract.md) if the contract surface itself changed (or supersede with a new ADR). +1. Bump `__contract_version__` per [ADR-002](../docs/adr/ADR-002-plugin-versioning.md): major for removals or signature changes, minor for additive optional hooks, patch for docs. +2. Update [ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md) if the contract surface itself changed (or supersede with a new ADR). 3. Add or update the test in `tests/test_plugin_contract.py` that asserts the new behavior. The skill `defining-asset-contract` walks through the full checklist. diff --git a/docs/PLUGIN-QUICKSTART.md b/docs/PLUGIN-QUICKSTART.md index d92c7b2..1c38c6b 100644 --- a/docs/PLUGIN-QUICKSTART.md +++ b/docs/PLUGIN-QUICKSTART.md @@ -3,7 +3,7 @@ Build a working shopdb-flask plugin in 30 minutes. This walks through generating, customizing, installing, and testing a plugin from scratch. For the full hook reference, see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md). -For the architectural decisions behind the contract, see [migrations/adr/](../migrations/adr/). +For the architectural decisions behind the contract, see [docs/adr/](../docs/adr/). ## Step 1: Generate the skeleton @@ -150,9 +150,9 @@ Copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) - [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) for the full hook reference - [CONTRIBUTING.md](../CONTRIBUTING.md) for naming conventions -- [migrations/adr/ADR-001-asset-as-platform-contract.md](../migrations/adr/ADR-001-asset-as-platform-contract.md) for what your plugin can rely on -- [migrations/adr/ADR-006-collector-contract.md](../migrations/adr/ADR-006-collector-contract.md) for accepting external collector input +- [docs/adr/ADR-001-asset-as-platform-contract.md](../docs/adr/ADR-001-asset-as-platform-contract.md) for what your plugin can rely on +- [docs/adr/ADR-006-collector-contract.md](../docs/adr/ADR-006-collector-contract.md) for accepting external collector input ## Distribution -If you are building a plugin for a specific GE Aerospace site (sister-site adoption), ship it as its own git repo. The site running shopdb-flask clones or symlinks your plugin into `/plugins//`. See [ADR-003](../migrations/adr/ADR-003-plugin-distribution.md). +If you are building a plugin for a specific GE Aerospace site (sister-site adoption), ship it as its own git repo. The site running shopdb-flask clones or symlinks your plugin into `/plugins//`. See [ADR-003](../docs/adr/ADR-003-plugin-distribution.md). diff --git a/migrations/adr/ADR-001-asset-as-platform-contract.md b/docs/adr/ADR-001-asset-as-platform-contract.md similarity index 100% rename from migrations/adr/ADR-001-asset-as-platform-contract.md rename to docs/adr/ADR-001-asset-as-platform-contract.md diff --git a/migrations/adr/ADR-002-plugin-versioning.md b/docs/adr/ADR-002-plugin-versioning.md similarity index 100% rename from migrations/adr/ADR-002-plugin-versioning.md rename to docs/adr/ADR-002-plugin-versioning.md diff --git a/migrations/adr/ADR-003-plugin-distribution.md b/docs/adr/ADR-003-plugin-distribution.md similarity index 100% rename from migrations/adr/ADR-003-plugin-distribution.md rename to docs/adr/ADR-003-plugin-distribution.md diff --git a/migrations/adr/ADR-004-deployment-topology.md b/docs/adr/ADR-004-deployment-topology.md similarity index 100% rename from migrations/adr/ADR-004-deployment-topology.md rename to docs/adr/ADR-004-deployment-topology.md diff --git a/migrations/adr/ADR-005-equipment-vs-measuringtools.md b/docs/adr/ADR-005-equipment-vs-measuringtools.md similarity index 100% rename from migrations/adr/ADR-005-equipment-vs-measuringtools.md rename to docs/adr/ADR-005-equipment-vs-measuringtools.md diff --git a/migrations/adr/ADR-006-collector-contract.md b/docs/adr/ADR-006-collector-contract.md similarity index 100% rename from migrations/adr/ADR-006-collector-contract.md rename to docs/adr/ADR-006-collector-contract.md diff --git a/migrations/adr/README.md b/docs/adr/README.md similarity index 100% rename from migrations/adr/README.md rename to docs/adr/README.md diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/68b3947ae14f_baseline_schema.py b/migrations/versions/68b3947ae14f_baseline_schema.py new file mode 100644 index 0000000..faab40d --- /dev/null +++ b/migrations/versions/68b3947ae14f_baseline_schema.py @@ -0,0 +1,956 @@ +"""baseline schema + +Revision ID: 68b3947ae14f +Revises: +Create Date: 2026-05-08 17:53:08.342776 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '68b3947ae14f' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('appowners', + sa.Column('appownerid', sa.Integer(), nullable=False), + sa.Column('appowner', sa.String(length=100), nullable=False), + sa.Column('sso', sa.String(length=50), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('appownerid') + ) + op.create_table('assetstatuses', + sa.Column('statusid', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('statusid'), + sa.UniqueConstraint('status') + ) + op.create_table('assettypes', + sa.Column('assettypeid', sa.Integer(), nullable=False), + sa.Column('assettype', sa.String(length=50), nullable=False, comment='Category name: equipment, computer, network_device, printer'), + sa.Column('pluginname', sa.String(length=100), nullable=True, comment='Plugin that owns this type'), + sa.Column('tablename', sa.String(length=100), nullable=True, comment='Extension table name for this type'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('assettypeid'), + sa.UniqueConstraint('assettype') + ) + op.create_table('businessunits', + sa.Column('businessunitid', sa.Integer(), nullable=False), + sa.Column('businessunit', sa.String(length=100), nullable=False), + sa.Column('code', sa.String(length=20), nullable=True, comment='Short code'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('parentid', sa.Integer(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['parentid'], ['businessunits.businessunitid'], ), + sa.PrimaryKeyConstraint('businessunitid'), + sa.UniqueConstraint('businessunit'), + sa.UniqueConstraint('code') + ) + op.create_table('communicationtypes', + sa.Column('comtypeid', sa.Integer(), nullable=False), + sa.Column('comtype', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('comtypeid'), + sa.UniqueConstraint('comtype') + ) + op.create_table('computertypes', + sa.Column('computertypeid', sa.Integer(), nullable=False), + sa.Column('computertype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('computertypeid'), + sa.UniqueConstraint('computertype') + ) + op.create_table('equipmenttypes', + sa.Column('equipmenttypeid', sa.Integer(), nullable=False), + sa.Column('equipmenttype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('equipmenttypeid'), + sa.UniqueConstraint('equipmenttype') + ) + op.create_table('locations', + sa.Column('locationid', sa.Integer(), nullable=False), + sa.Column('locationname', sa.String(length=100), nullable=False), + sa.Column('building', sa.String(length=100), nullable=True), + sa.Column('floor', sa.String(length=50), nullable=True), + sa.Column('room', sa.String(length=50), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('mapimage', sa.String(length=500), nullable=True, comment='Path to floor map image'), + sa.Column('mapwidth', sa.Integer(), nullable=True), + sa.Column('mapheight', sa.Integer(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('locationid'), + sa.UniqueConstraint('locationname') + ) + op.create_table('machinestatuses', + sa.Column('statusid', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('statusid'), + sa.UniqueConstraint('status') + ) + op.create_table('machinetypes', + sa.Column('machinetypeid', sa.Integer(), nullable=False), + sa.Column('machinetype', sa.String(length=100), nullable=False), + sa.Column('category', sa.String(length=50), nullable=False, comment='Equipment, PC, Network, or Printer'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('machinetypeid'), + sa.UniqueConstraint('machinetype') + ) + op.create_table('networkdevicetypes', + sa.Column('networkdevicetypeid', sa.Integer(), nullable=False), + sa.Column('networkdevicetype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('networkdevicetypeid'), + sa.UniqueConstraint('networkdevicetype') + ) + op.create_table('notificationtypes', + sa.Column('notificationtypeid', sa.Integer(), nullable=False), + sa.Column('typename', sa.String(length=50), nullable=False), + sa.Column('typedescription', sa.Text(), nullable=True), + sa.Column('typecolor', sa.String(length=20), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('notificationtypeid') + ) + op.create_table('operatingsystems', + sa.Column('osid', sa.Integer(), nullable=False), + sa.Column('osname', sa.String(length=100), nullable=False), + sa.Column('osversion', sa.String(length=50), nullable=True), + sa.Column('architecture', sa.String(length=20), nullable=True, comment='x86, x64, ARM'), + sa.Column('endoflife', sa.Date(), nullable=True, comment='End of support date'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('osid'), + sa.UniqueConstraint('osname', 'osversion', name='uq_os_name_version') + ) + op.create_table('pctypes', + sa.Column('pctypeid', sa.Integer(), nullable=False), + sa.Column('pctype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('pctypeid'), + sa.UniqueConstraint('pctype') + ) + op.create_table('permissions', + sa.Column('permissionid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('category', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('permissionid'), + sa.UniqueConstraint('name') + ) + op.create_table('printertypes', + sa.Column('printertypeid', sa.Integer(), nullable=False), + sa.Column('printertype', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('printertypeid'), + sa.UniqueConstraint('printertype') + ) + op.create_table('relationshiptypes', + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('relationshiptype', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('relationshiptypeid'), + sa.UniqueConstraint('relationshiptype') + ) + op.create_table('roles', + sa.Column('roleid', sa.Integer(), nullable=False), + sa.Column('rolename', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('roleid'), + sa.UniqueConstraint('rolename') + ) + op.create_table('settings', + sa.Column('settingid', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('key', sa.String(length=100), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.Column('valuetype', sa.String(length=20), nullable=True), + sa.Column('category', sa.String(length=50), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=True), + sa.Column('modifieddate', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('settingid') + ) + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_settings_key'), ['key'], unique=True) + + op.create_table('usbdevicetypes', + sa.Column('usbdevicetypeid', sa.Integer(), nullable=False), + sa.Column('typename', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('usbdevicetypeid'), + sa.UniqueConstraint('typename') + ) + op.create_table('users', + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('passwordhash', sa.String(length=255), nullable=False), + sa.Column('firstname', sa.String(length=100), nullable=True), + sa.Column('lastname', sa.String(length=100), nullable=True), + sa.Column('lastlogindate', sa.DateTime(), nullable=True), + sa.Column('failedlogins', sa.Integer(), nullable=True), + sa.Column('lockeduntil', sa.DateTime(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('userid'), + sa.UniqueConstraint('email') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True) + + op.create_table('vendors', + sa.Column('vendorid', sa.Integer(), nullable=False), + sa.Column('vendor', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('website', sa.String(length=255), nullable=True), + sa.Column('supportphone', sa.String(length=50), nullable=True), + sa.Column('supportemail', sa.String(length=100), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('vendorid'), + sa.UniqueConstraint('vendor') + ) + op.create_table('vlans', + sa.Column('vlanid', sa.Integer(), nullable=False), + sa.Column('vlannumber', sa.Integer(), nullable=False, comment='VLAN ID number'), + sa.Column('name', sa.String(length=100), nullable=False, comment='VLAN name'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('vlantype', sa.String(length=50), nullable=True, comment='Type: data, voice, management, guest, etc.'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('vlanid'), + sa.UniqueConstraint('vlannumber') + ) + with op.batch_alter_table('vlans', schema=None) as batch_op: + batch_op.create_index('idx_vlan_number', ['vlannumber'], unique=False) + + op.create_table('assets', + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('assetnumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'), + sa.Column('name', sa.String(length=100), nullable=True, comment='Display name/alias'), + sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'), + sa.Column('assettypeid', sa.Integer(), nullable=False), + sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'), + sa.Column('locationid', sa.Integer(), nullable=True), + sa.Column('businessunitid', sa.Integer(), nullable=True), + sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'), + sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('deleteddate', sa.DateTime(), nullable=True), + sa.Column('deletedby', sa.String(length=100), nullable=True), + sa.Column('createdby', sa.String(length=100), nullable=True), + sa.Column('modifiedby', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['assettypeid'], ['assettypes.assettypeid'], ), + sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ), + sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ), + sa.ForeignKeyConstraint(['statusid'], ['assetstatuses.statusid'], ), + sa.PrimaryKeyConstraint('assetid') + ) + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.create_index('idx_asset_active', ['isactive'], unique=False) + batch_op.create_index('idx_asset_location', ['locationid'], unique=False) + batch_op.create_index('idx_asset_status', ['statusid'], unique=False) + batch_op.create_index('idx_asset_type_bu', ['assettypeid', 'businessunitid'], unique=False) + batch_op.create_index(batch_op.f('ix_assets_assetnumber'), ['assetnumber'], unique=True) + batch_op.create_index(batch_op.f('ix_assets_serialnumber'), ['serialnumber'], unique=False) + + op.create_table('auditlogs', + sa.Column('auditlogid', sa.Integer(), nullable=False), + sa.Column('userid', sa.Integer(), nullable=True), + sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('ipaddress', sa.String(length=45), nullable=True), + sa.Column('useragent', sa.String(length=255), nullable=True), + sa.Column('action', sa.String(length=20), nullable=False), + sa.Column('entitytype', sa.String(length=50), nullable=False), + sa.Column('entityid', sa.Integer(), nullable=True), + sa.Column('entityname', sa.String(length=255), nullable=True), + sa.Column('changes', sa.Text(), nullable=True), + sa.Column('details', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['userid'], ['users.userid'], ), + sa.PrimaryKeyConstraint('auditlogid') + ) + with op.batch_alter_table('auditlogs', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_auditlogs_action'), ['action'], unique=False) + batch_op.create_index(batch_op.f('ix_auditlogs_entitytype'), ['entitytype'], unique=False) + batch_op.create_index(batch_op.f('ix_auditlogs_timestamp'), ['timestamp'], unique=False) + + op.create_table('models', + sa.Column('modelnumberid', sa.Integer(), nullable=False), + sa.Column('modelnumber', sa.String(length=100), nullable=False), + sa.Column('machinetypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('imageurl', sa.String(length=500), nullable=True, comment='URL to product image'), + sa.Column('documentationurl', sa.String(length=500), nullable=True, comment='URL to documentation'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('modelnumberid'), + sa.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor') + ) + op.create_table('notifications', + sa.Column('notificationid', sa.Integer(), nullable=False), + sa.Column('notificationtypeid', sa.Integer(), nullable=True), + sa.Column('businessunitid', sa.Integer(), nullable=True), + sa.Column('appid', sa.Integer(), nullable=True), + sa.Column('notification', sa.Text(), nullable=False, comment='The message content'), + sa.Column('starttime', sa.DateTime(), nullable=True), + sa.Column('endtime', sa.DateTime(), nullable=True), + sa.Column('ticketnumber', sa.String(length=50), nullable=True), + sa.Column('link', sa.String(length=500), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.Column('isshopfloor', sa.Boolean(), nullable=True), + sa.Column('employeesso', sa.String(length=100), nullable=True), + sa.Column('employeename', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['notificationtypeid'], ['notificationtypes.notificationtypeid'], ), + sa.PrimaryKeyConstraint('notificationid') + ) + op.create_table('rolepermissions', + sa.Column('roleid', sa.Integer(), nullable=False), + sa.Column('permissionid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['permissionid'], ['permissions.permissionid'], ), + sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ), + sa.PrimaryKeyConstraint('roleid', 'permissionid') + ) + op.create_table('subnets', + sa.Column('subnetid', sa.Integer(), nullable=False), + sa.Column('cidr', sa.String(length=18), nullable=False, comment='CIDR notation (e.g., 10.1.1.0/24)'), + sa.Column('name', sa.String(length=100), nullable=False, comment='Subnet name'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('gatewayip', sa.String(length=15), nullable=True, comment='Default gateway IP address'), + sa.Column('subnetmask', sa.String(length=15), nullable=True, comment='Subnet mask (e.g., 255.255.255.0)'), + sa.Column('networkaddress', sa.String(length=15), nullable=True, comment='Network address (e.g., 10.1.1.0)'), + sa.Column('broadcastaddress', sa.String(length=15), nullable=True, comment='Broadcast address (e.g., 10.1.1.255)'), + sa.Column('vlanid', sa.Integer(), nullable=True), + sa.Column('subnettype', sa.String(length=50), nullable=True, comment='Type: production, development, management, dmz, etc.'), + sa.Column('locationid', sa.Integer(), nullable=True), + sa.Column('dhcpenabled', sa.Boolean(), nullable=True, comment='DHCP enabled for this subnet'), + sa.Column('dhcprangestart', sa.String(length=15), nullable=True, comment='DHCP range start IP'), + sa.Column('dhcprangeend', sa.String(length=15), nullable=True, comment='DHCP range end IP'), + sa.Column('dns1', sa.String(length=15), nullable=True, comment='Primary DNS server'), + sa.Column('dns2', sa.String(length=15), nullable=True, comment='Secondary DNS server'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ), + sa.ForeignKeyConstraint(['vlanid'], ['vlans.vlanid'], ), + sa.PrimaryKeyConstraint('subnetid'), + sa.UniqueConstraint('cidr') + ) + with op.batch_alter_table('subnets', schema=None) as batch_op: + batch_op.create_index('idx_subnet_cidr', ['cidr'], unique=False) + batch_op.create_index('idx_subnet_location', ['locationid'], unique=False) + batch_op.create_index('idx_subnet_vlan', ['vlanid'], unique=False) + + op.create_table('supportteams', + sa.Column('supportteamid', sa.Integer(), nullable=False), + sa.Column('teamname', sa.String(length=100), nullable=False), + sa.Column('teamurl', sa.String(length=255), nullable=True), + sa.Column('appownerid', sa.Integer(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['appownerid'], ['appowners.appownerid'], ), + sa.PrimaryKeyConstraint('supportteamid') + ) + op.create_table('usbdevices', + sa.Column('usbdeviceid', sa.Integer(), nullable=False), + sa.Column('serialnumber', sa.String(length=100), nullable=False), + sa.Column('label', sa.String(length=100), nullable=True, comment='Human-readable label'), + sa.Column('assetnumber', sa.String(length=50), nullable=True, comment='Optional asset tag'), + sa.Column('usbdevicetypeid', sa.Integer(), nullable=True), + sa.Column('capacitygb', sa.Integer(), nullable=True, comment='Capacity in GB'), + sa.Column('vendorid', sa.String(length=10), nullable=True, comment='USB Vendor ID (hex)'), + sa.Column('productid', sa.String(length=10), nullable=True, comment='USB Product ID (hex)'), + sa.Column('manufacturer', sa.String(length=100), nullable=True), + sa.Column('productname', sa.String(length=100), nullable=True), + sa.Column('ischeckedout', sa.Boolean(), nullable=True), + sa.Column('currentuserid', sa.String(length=50), nullable=True, comment='SSO of current user'), + sa.Column('currentusername', sa.String(length=100), nullable=True, comment='Name of current user'), + sa.Column('currentcheckoutdate', sa.DateTime(), nullable=True), + sa.Column('storagelocation', sa.String(length=200), nullable=True, comment='Where device is stored when not checked out'), + sa.Column('pin', sa.String(length=50), nullable=True, comment='PIN for encrypted devices'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('createdby', sa.String(length=100), nullable=True), + sa.Column('modifiedby', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['usbdevicetypeid'], ['usbdevicetypes.usbdevicetypeid'], ), + sa.PrimaryKeyConstraint('usbdeviceid'), + sa.UniqueConstraint('serialnumber') + ) + with op.batch_alter_table('usbdevices', schema=None) as batch_op: + batch_op.create_index('idx_usb_checkedout', ['ischeckedout'], unique=False) + batch_op.create_index('idx_usb_currentuser', ['currentuserid'], unique=False) + batch_op.create_index('idx_usb_serial', ['serialnumber'], unique=False) + batch_op.create_index('idx_usb_type', ['usbdevicetypeid'], unique=False) + + op.create_table('userroles', + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('roleid', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ), + sa.ForeignKeyConstraint(['userid'], ['users.userid'], ), + sa.PrimaryKeyConstraint('userid', 'roleid') + ) + op.create_table('applications', + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('appname', sa.String(length=100), nullable=False), + sa.Column('appdescription', sa.String(length=255), nullable=True), + sa.Column('supportteamid', sa.Integer(), nullable=True), + sa.Column('isinstallable', sa.Boolean(), nullable=True), + sa.Column('applicationnotes', sa.Text(), nullable=True), + sa.Column('installpath', sa.String(length=255), nullable=True), + sa.Column('applicationlink', sa.String(length=512), nullable=True), + sa.Column('documentationpath', sa.String(length=512), nullable=True), + sa.Column('ishidden', sa.Boolean(), nullable=True), + sa.Column('isprinter', sa.Boolean(), nullable=True), + sa.Column('islicenced', sa.Boolean(), nullable=True), + sa.Column('image', sa.String(length=255), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['supportteamid'], ['supportteams.supportteamid'], ), + sa.PrimaryKeyConstraint('appid'), + sa.UniqueConstraint('appname') + ) + op.create_table('assetrelationships', + sa.Column('relationshipid', sa.Integer(), nullable=False), + sa.Column('sourceassetid', sa.Integer(), nullable=False), + sa.Column('targetassetid', sa.Integer(), nullable=False), + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ), + sa.ForeignKeyConstraint(['sourceassetid'], ['assets.assetid'], ), + sa.ForeignKeyConstraint(['targetassetid'], ['assets.assetid'], ), + sa.PrimaryKeyConstraint('relationshipid'), + sa.UniqueConstraint('sourceassetid', 'targetassetid', 'relationshiptypeid', name='uq_asset_relationship') + ) + with op.batch_alter_table('assetrelationships', schema=None) as batch_op: + batch_op.create_index('idx_asset_rel_source', ['sourceassetid'], unique=False) + batch_op.create_index('idx_asset_rel_target', ['targetassetid'], unique=False) + + op.create_table('computers', + sa.Column('computerid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('computertypeid', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'), + sa.Column('osid', sa.Integer(), nullable=True), + sa.Column('loggedinuser', sa.String(length=100), nullable=True), + sa.Column('lastreporteddate', sa.DateTime(), nullable=True), + sa.Column('lastboottime', sa.DateTime(), nullable=True), + sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'), + sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'), + sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC (vs office PC)'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['computertypeid'], ['computertypes.computertypeid'], ), + sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ), + sa.PrimaryKeyConstraint('computerid') + ) + with op.batch_alter_table('computers', schema=None) as batch_op: + batch_op.create_index('idx_computer_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_computer_os', ['osid'], unique=False) + batch_op.create_index('idx_computer_type', ['computertypeid'], unique=False) + batch_op.create_index(batch_op.f('ix_computers_assetid'), ['assetid'], unique=True) + batch_op.create_index(batch_op.f('ix_computers_hostname'), ['hostname'], unique=False) + + op.create_table('equipment', + sa.Column('equipmentid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('equipmenttypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'), + sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual equipment)'), + sa.Column('lastmaintenancedate', sa.DateTime(), nullable=True), + sa.Column('nextmaintenancedate', sa.DateTime(), nullable=True), + sa.Column('maintenanceintervaldays', sa.Integer(), nullable=True), + sa.Column('controllervendorid', sa.Integer(), nullable=True, comment='Controller vendor (e.g., FANUC)'), + sa.Column('controllermodelid', sa.Integer(), nullable=True, comment='Controller model (e.g., 31B)'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['controllermodelid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['controllervendorid'], ['vendors.vendorid'], ), + sa.ForeignKeyConstraint(['equipmenttypeid'], ['equipmenttypes.equipmenttypeid'], ), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('equipmentid') + ) + with op.batch_alter_table('equipment', schema=None) as batch_op: + batch_op.create_index('idx_equipment_type', ['equipmenttypeid'], unique=False) + batch_op.create_index('idx_equipment_vendor', ['vendorid'], unique=False) + batch_op.create_index(batch_op.f('ix_equipment_assetid'), ['assetid'], unique=True) + + op.create_table('machines', + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('machinenumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'), + sa.Column('alias', sa.String(length=100), nullable=True, comment='Friendly name'), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname (for PCs)'), + sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'), + sa.Column('machinetypeid', sa.Integer(), nullable=False), + sa.Column('pctypeid', sa.Integer(), nullable=True, comment='Set for PCs, NULL for equipment'), + sa.Column('businessunitid', sa.Integer(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'), + sa.Column('locationid', sa.Integer(), nullable=True), + sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'), + sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'), + sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual machine)'), + sa.Column('osid', sa.Integer(), nullable=True), + sa.Column('loggedinuser', sa.String(length=100), nullable=True), + sa.Column('lastreporteddate', sa.DateTime(), nullable=True), + sa.Column('lastboottime', sa.DateTime(), nullable=True), + sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'), + sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'), + sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC'), + sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('deleteddate', sa.DateTime(), nullable=True), + sa.Column('deletedby', sa.String(length=100), nullable=True), + sa.Column('createdby', sa.String(length=100), nullable=True), + sa.Column('modifiedby', sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ), + sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ), + sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ), + sa.ForeignKeyConstraint(['pctypeid'], ['pctypes.pctypeid'], ), + sa.ForeignKeyConstraint(['statusid'], ['machinestatuses.statusid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('machineid') + ) + with op.batch_alter_table('machines', schema=None) as batch_op: + batch_op.create_index('idx_machine_active', ['isactive'], unique=False) + batch_op.create_index('idx_machine_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_machine_location', ['locationid'], unique=False) + batch_op.create_index('idx_machine_type_bu', ['machinetypeid', 'businessunitid'], unique=False) + batch_op.create_index(batch_op.f('ix_machines_hostname'), ['hostname'], unique=False) + batch_op.create_index(batch_op.f('ix_machines_machinenumber'), ['machinenumber'], unique=True) + batch_op.create_index(batch_op.f('ix_machines_serialnumber'), ['serialnumber'], unique=False) + + op.create_table('networkdevices', + sa.Column('networkdeviceid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('networkdevicetypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'), + sa.Column('firmwareversion', sa.String(length=100), nullable=True), + sa.Column('portcount', sa.Integer(), nullable=True, comment='Number of ports (for switches)'), + sa.Column('ispoe', sa.Boolean(), nullable=True, comment='Power over Ethernet capable'), + sa.Column('ismanaged', sa.Boolean(), nullable=True, comment='Managed device (SNMP, web interface, etc.)'), + sa.Column('rackunit', sa.String(length=20), nullable=True, comment='Rack unit position (e.g., U1, U5)'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['networkdevicetypeid'], ['networkdevicetypes.networkdevicetypeid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('networkdeviceid') + ) + with op.batch_alter_table('networkdevices', schema=None) as batch_op: + batch_op.create_index('idx_netdev_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_netdev_type', ['networkdevicetypeid'], unique=False) + batch_op.create_index('idx_netdev_vendor', ['vendorid'], unique=False) + batch_op.create_index(batch_op.f('ix_networkdevices_assetid'), ['assetid'], unique=True) + batch_op.create_index(batch_op.f('ix_networkdevices_hostname'), ['hostname'], unique=False) + + op.create_table('printers', + sa.Column('printerid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('printertypeid', sa.Integer(), nullable=True), + sa.Column('vendorid', sa.Integer(), nullable=True), + sa.Column('modelnumberid', sa.Integer(), nullable=True), + sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'), + sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'), + sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'), + sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'), + sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'), + sa.Column('pin', sa.String(length=20), nullable=True), + sa.Column('iscolor', sa.Boolean(), nullable=True, comment='Color capable'), + sa.Column('isduplex', sa.Boolean(), nullable=True, comment='Duplex capable'), + sa.Column('isnetwork', sa.Boolean(), nullable=True, comment='Network connected'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ), + sa.ForeignKeyConstraint(['printertypeid'], ['printertypes.printertypeid'], ), + sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ), + sa.PrimaryKeyConstraint('printerid') + ) + with op.batch_alter_table('printers', schema=None) as batch_op: + batch_op.create_index('idx_printer_hostname', ['hostname'], unique=False) + batch_op.create_index('idx_printer_type', ['printertypeid'], unique=False) + batch_op.create_index('idx_printer_windowsname', ['windowsname'], unique=False) + batch_op.create_index(batch_op.f('ix_printers_assetid'), ['assetid'], unique=True) + batch_op.create_index(batch_op.f('ix_printers_hostname'), ['hostname'], unique=False) + + op.create_table('usbcheckouts', + sa.Column('checkoutid', sa.Integer(), nullable=False), + sa.Column('usbdeviceid', sa.Integer(), nullable=True), + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('sso', sa.String(length=20), nullable=False, comment='SSO of user'), + sa.Column('checkoutname', sa.String(length=100), nullable=True, comment='Name of user'), + sa.Column('checkouttime', sa.DateTime(), nullable=False), + sa.Column('checkintime', sa.DateTime(), nullable=True), + sa.Column('checkoutreason', sa.Text(), nullable=True, comment='Reason for checkout'), + sa.Column('checkinnotes', sa.Text(), nullable=True), + sa.Column('waswiped', sa.Boolean(), nullable=True, comment='Was device wiped after return'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['usbdeviceid'], ['usbdevices.usbdeviceid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('checkoutid') + ) + op.create_table('appversions', + sa.Column('appversionid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('version', sa.String(length=50), nullable=False), + sa.Column('releasedate', sa.Date(), nullable=True), + sa.Column('notes', sa.String(length=255), nullable=True), + sa.Column('dateadded', sa.DateTime(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.PrimaryKeyConstraint('appversionid'), + sa.UniqueConstraint('appid', 'version', name='uq_app_version') + ) + op.create_table('communications', + sa.Column('communicationid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=True, comment='FK to assets table (new architecture)'), + sa.Column('machineid', sa.Integer(), nullable=True, comment='DEPRECATED: FK to machines table - use assetid instead'), + sa.Column('comtypeid', sa.Integer(), nullable=False), + sa.Column('ipaddress', sa.String(length=50), nullable=True), + sa.Column('subnetmask', sa.String(length=50), nullable=True), + sa.Column('gateway', sa.String(length=50), nullable=True), + sa.Column('dns1', sa.String(length=50), nullable=True), + sa.Column('dns2', sa.String(length=50), nullable=True), + sa.Column('macaddress', sa.String(length=50), nullable=True), + sa.Column('isdhcp', sa.Boolean(), nullable=True), + sa.Column('comport', sa.String(length=20), nullable=True), + sa.Column('baudrate', sa.Integer(), nullable=True), + sa.Column('databits', sa.Integer(), nullable=True), + sa.Column('stopbits', sa.String(length=10), nullable=True), + sa.Column('parity', sa.String(length=20), nullable=True), + sa.Column('flowcontrol', sa.String(length=20), nullable=True), + sa.Column('port', sa.Integer(), nullable=True), + sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('pathname', sa.String(length=255), nullable=True), + sa.Column('pathname2', sa.String(length=255), nullable=True, comment='Secondary path for dualpath'), + sa.Column('isprimary', sa.Boolean(), nullable=True, comment='Primary communication method'), + sa.Column('ismachinenetwork', sa.Boolean(), nullable=True, comment='On machine network vs office network'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ), + sa.ForeignKeyConstraint(['comtypeid'], ['communicationtypes.comtypeid'], ), + sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ), + sa.PrimaryKeyConstraint('communicationid') + ) + with op.batch_alter_table('communications', schema=None) as batch_op: + batch_op.create_index('idx_comm_asset', ['assetid'], unique=False) + batch_op.create_index('idx_comm_ip', ['ipaddress'], unique=False) + batch_op.create_index('idx_comm_machine', ['machineid'], unique=False) + batch_op.create_index(batch_op.f('ix_communications_assetid'), ['assetid'], unique=False) + + op.create_table('knowledgebase', + sa.Column('linkid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=True), + sa.Column('shortdescription', sa.String(length=500), nullable=False), + sa.Column('linkurl', sa.String(length=2000), nullable=True), + sa.Column('keywords', sa.String(length=500), nullable=True), + sa.Column('clicks', sa.Integer(), nullable=True), + sa.Column('lastupdated', sa.DateTime(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.PrimaryKeyConstraint('linkid') + ) + op.create_table('machinerelationships', + sa.Column('relationshipid', sa.Integer(), nullable=False), + sa.Column('parentmachineid', sa.Integer(), nullable=False), + sa.Column('childmachineid', sa.Integer(), nullable=False), + sa.Column('relationshiptypeid', sa.Integer(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['childmachineid'], ['machines.machineid'], ), + sa.ForeignKeyConstraint(['parentmachineid'], ['machines.machineid'], ), + sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ), + sa.PrimaryKeyConstraint('relationshipid'), + sa.UniqueConstraint('parentmachineid', 'childmachineid', 'relationshiptypeid', name='uq_machine_relationship') + ) + op.create_table('printerdata', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'), + sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'), + sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'), + sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'), + sa.Column('pin', sa.String(length=20), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('printerdata', schema=None) as batch_op: + batch_op.create_index('idx_printerdata_windowsname', ['windowsname'], unique=False) + batch_op.create_index(batch_op.f('ix_printerdata_machineid'), ['machineid'], unique=True) + + op.create_table('computerinstalledapps', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('computerid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('appversionid', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('installeddate', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ), + sa.ForeignKeyConstraint(['computerid'], ['computers.computerid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('computerid', 'appid', name='uq_computer_app') + ) + with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op: + batch_op.create_index('idx_compapp_app', ['appid'], unique=False) + batch_op.create_index('idx_compapp_computer', ['computerid'], unique=False) + + op.create_table('installedapps', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('machineid', sa.Integer(), nullable=False), + sa.Column('appid', sa.Integer(), nullable=False), + sa.Column('appversionid', sa.Integer(), nullable=True), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.Column('installeddate', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ), + sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ), + sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('machineid', 'appid', name='uq_machine_app') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('installedapps') + with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op: + batch_op.drop_index('idx_compapp_computer') + batch_op.drop_index('idx_compapp_app') + + op.drop_table('computerinstalledapps') + with op.batch_alter_table('printerdata', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_printerdata_machineid')) + batch_op.drop_index('idx_printerdata_windowsname') + + op.drop_table('printerdata') + op.drop_table('machinerelationships') + op.drop_table('knowledgebase') + with op.batch_alter_table('communications', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_communications_assetid')) + batch_op.drop_index('idx_comm_machine') + batch_op.drop_index('idx_comm_ip') + batch_op.drop_index('idx_comm_asset') + + op.drop_table('communications') + op.drop_table('appversions') + op.drop_table('usbcheckouts') + with op.batch_alter_table('printers', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_printers_hostname')) + batch_op.drop_index(batch_op.f('ix_printers_assetid')) + batch_op.drop_index('idx_printer_windowsname') + batch_op.drop_index('idx_printer_type') + batch_op.drop_index('idx_printer_hostname') + + op.drop_table('printers') + with op.batch_alter_table('networkdevices', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_networkdevices_hostname')) + batch_op.drop_index(batch_op.f('ix_networkdevices_assetid')) + batch_op.drop_index('idx_netdev_vendor') + batch_op.drop_index('idx_netdev_type') + batch_op.drop_index('idx_netdev_hostname') + + op.drop_table('networkdevices') + with op.batch_alter_table('machines', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_machines_serialnumber')) + batch_op.drop_index(batch_op.f('ix_machines_machinenumber')) + batch_op.drop_index(batch_op.f('ix_machines_hostname')) + batch_op.drop_index('idx_machine_type_bu') + batch_op.drop_index('idx_machine_location') + batch_op.drop_index('idx_machine_hostname') + batch_op.drop_index('idx_machine_active') + + op.drop_table('machines') + with op.batch_alter_table('equipment', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_equipment_assetid')) + batch_op.drop_index('idx_equipment_vendor') + batch_op.drop_index('idx_equipment_type') + + op.drop_table('equipment') + with op.batch_alter_table('computers', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_computers_hostname')) + batch_op.drop_index(batch_op.f('ix_computers_assetid')) + batch_op.drop_index('idx_computer_type') + batch_op.drop_index('idx_computer_os') + batch_op.drop_index('idx_computer_hostname') + + op.drop_table('computers') + with op.batch_alter_table('assetrelationships', schema=None) as batch_op: + batch_op.drop_index('idx_asset_rel_target') + batch_op.drop_index('idx_asset_rel_source') + + op.drop_table('assetrelationships') + op.drop_table('applications') + op.drop_table('userroles') + with op.batch_alter_table('usbdevices', schema=None) as batch_op: + batch_op.drop_index('idx_usb_type') + batch_op.drop_index('idx_usb_serial') + batch_op.drop_index('idx_usb_currentuser') + batch_op.drop_index('idx_usb_checkedout') + + op.drop_table('usbdevices') + op.drop_table('supportteams') + with op.batch_alter_table('subnets', schema=None) as batch_op: + batch_op.drop_index('idx_subnet_vlan') + batch_op.drop_index('idx_subnet_location') + batch_op.drop_index('idx_subnet_cidr') + + op.drop_table('subnets') + op.drop_table('rolepermissions') + op.drop_table('notifications') + op.drop_table('models') + with op.batch_alter_table('auditlogs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_auditlogs_timestamp')) + batch_op.drop_index(batch_op.f('ix_auditlogs_entitytype')) + batch_op.drop_index(batch_op.f('ix_auditlogs_action')) + + op.drop_table('auditlogs') + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_assets_serialnumber')) + batch_op.drop_index(batch_op.f('ix_assets_assetnumber')) + batch_op.drop_index('idx_asset_type_bu') + batch_op.drop_index('idx_asset_status') + batch_op.drop_index('idx_asset_location') + batch_op.drop_index('idx_asset_active') + + op.drop_table('assets') + with op.batch_alter_table('vlans', schema=None) as batch_op: + batch_op.drop_index('idx_vlan_number') + + op.drop_table('vlans') + op.drop_table('vendors') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_username')) + + op.drop_table('users') + op.drop_table('usbdevicetypes') + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_settings_key')) + + op.drop_table('settings') + op.drop_table('roles') + op.drop_table('relationshiptypes') + op.drop_table('printertypes') + op.drop_table('permissions') + op.drop_table('pctypes') + op.drop_table('operatingsystems') + op.drop_table('notificationtypes') + op.drop_table('networkdevicetypes') + op.drop_table('machinetypes') + op.drop_table('machinestatuses') + op.drop_table('locations') + op.drop_table('equipmenttypes') + op.drop_table('computertypes') + op.drop_table('communicationtypes') + op.drop_table('businessunits') + op.drop_table('assettypes') + op.drop_table('assetstatuses') + op.drop_table('appowners') + # ### end Alembic commands ###