diff --git a/plugins/computers/migrations/alembic.ini b/plugins/computers/migrations/alembic.ini new file mode 100644 index 0000000..0775ad4 --- /dev/null +++ b/plugins/computers/migrations/alembic.ini @@ -0,0 +1,30 @@ +[alembic] +script_location = . +prepend_sys_path = . +file_template = %%(rev)s_%%(slug)s + +[logging] +keys = root + +[loggers] +keys = root + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console +qualname = + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = INFO +formatter = generic + +[formatter_generic] +format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/computers/migrations/env.py b/plugins/computers/migrations/env.py new file mode 100644 index 0000000..037991d --- /dev/null +++ b/plugins/computers/migrations/env.py @@ -0,0 +1,12 @@ +"""Alembic env.py for the computers plugin. + +Thin shim that sets PLUGIN_NAME then delegates to the shared template at +shopdb.plugins.alembic_template, which filters MetaData to only this +plugin's tables and runs Alembic against the Flask app's engine. +""" +import os +os.environ['PLUGIN_NAME'] = 'computers' + +from shopdb.plugins.alembic_template import run_migrations + +run_migrations() diff --git a/plugins/computers/migrations/script.py.mako b/plugins/computers/migrations/script.py.mako new file mode 100644 index 0000000..f230367 --- /dev/null +++ b/plugins/computers/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${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 = ${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/plugins/computers/migrations/versions/0001_baseline.py b/plugins/computers/migrations/versions/0001_baseline.py new file mode 100644 index 0000000..f2c21b1 --- /dev/null +++ b/plugins/computers/migrations/versions/0001_baseline.py @@ -0,0 +1,27 @@ +"""computers plugin: baseline schema + +Creates every table owned by the computers plugin per +shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions +are derived from the SQLAlchemy models at migration runtime so this stays +in lockstep with the model layer without duplication. + +Revision ID: 0001_baseline_computers +Revises: +Create Date: 2026-05-30 + +""" +from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables + + +revision = '0001_baseline_computers' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + create_plugin_tables('computers') + + +def downgrade(): + drop_plugin_tables('computers') diff --git a/plugins/equipment/migrations/alembic.ini b/plugins/equipment/migrations/alembic.ini new file mode 100644 index 0000000..0775ad4 --- /dev/null +++ b/plugins/equipment/migrations/alembic.ini @@ -0,0 +1,30 @@ +[alembic] +script_location = . +prepend_sys_path = . +file_template = %%(rev)s_%%(slug)s + +[logging] +keys = root + +[loggers] +keys = root + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console +qualname = + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = INFO +formatter = generic + +[formatter_generic] +format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/equipment/migrations/env.py b/plugins/equipment/migrations/env.py new file mode 100644 index 0000000..42ec87e --- /dev/null +++ b/plugins/equipment/migrations/env.py @@ -0,0 +1,12 @@ +"""Alembic env.py for the equipment plugin. + +Thin shim that sets PLUGIN_NAME then delegates to the shared template at +shopdb.plugins.alembic_template, which filters MetaData to only this +plugin's tables and runs Alembic against the Flask app's engine. +""" +import os +os.environ['PLUGIN_NAME'] = 'equipment' + +from shopdb.plugins.alembic_template import run_migrations + +run_migrations() diff --git a/plugins/equipment/migrations/script.py.mako b/plugins/equipment/migrations/script.py.mako new file mode 100644 index 0000000..f230367 --- /dev/null +++ b/plugins/equipment/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${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 = ${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/plugins/equipment/migrations/versions/0001_baseline.py b/plugins/equipment/migrations/versions/0001_baseline.py new file mode 100644 index 0000000..44e7837 --- /dev/null +++ b/plugins/equipment/migrations/versions/0001_baseline.py @@ -0,0 +1,27 @@ +"""equipment plugin: baseline schema + +Creates every table owned by the equipment plugin per +shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions +are derived from the SQLAlchemy models at migration runtime so this stays +in lockstep with the model layer without duplication. + +Revision ID: 0001_baseline_equipment +Revises: +Create Date: 2026-05-30 + +""" +from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables + + +revision = '0001_baseline_equipment' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + create_plugin_tables('equipment') + + +def downgrade(): + drop_plugin_tables('equipment') diff --git a/plugins/network/migrations/alembic.ini b/plugins/network/migrations/alembic.ini new file mode 100644 index 0000000..0775ad4 --- /dev/null +++ b/plugins/network/migrations/alembic.ini @@ -0,0 +1,30 @@ +[alembic] +script_location = . +prepend_sys_path = . +file_template = %%(rev)s_%%(slug)s + +[logging] +keys = root + +[loggers] +keys = root + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console +qualname = + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = INFO +formatter = generic + +[formatter_generic] +format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/network/migrations/env.py b/plugins/network/migrations/env.py new file mode 100644 index 0000000..3967a1a --- /dev/null +++ b/plugins/network/migrations/env.py @@ -0,0 +1,12 @@ +"""Alembic env.py for the network plugin. + +Thin shim that sets PLUGIN_NAME then delegates to the shared template at +shopdb.plugins.alembic_template, which filters MetaData to only this +plugin's tables and runs Alembic against the Flask app's engine. +""" +import os +os.environ['PLUGIN_NAME'] = 'network' + +from shopdb.plugins.alembic_template import run_migrations + +run_migrations() diff --git a/plugins/network/migrations/script.py.mako b/plugins/network/migrations/script.py.mako new file mode 100644 index 0000000..f230367 --- /dev/null +++ b/plugins/network/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${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 = ${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/plugins/network/migrations/versions/0001_baseline.py b/plugins/network/migrations/versions/0001_baseline.py new file mode 100644 index 0000000..2be8b72 --- /dev/null +++ b/plugins/network/migrations/versions/0001_baseline.py @@ -0,0 +1,27 @@ +"""network plugin: baseline schema + +Creates every table owned by the network plugin per +shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions +are derived from the SQLAlchemy models at migration runtime so this stays +in lockstep with the model layer without duplication. + +Revision ID: 0001_baseline_network +Revises: +Create Date: 2026-05-30 + +""" +from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables + + +revision = '0001_baseline_network' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + create_plugin_tables('network') + + +def downgrade(): + drop_plugin_tables('network') diff --git a/plugins/notifications/migrations/alembic.ini b/plugins/notifications/migrations/alembic.ini new file mode 100644 index 0000000..0775ad4 --- /dev/null +++ b/plugins/notifications/migrations/alembic.ini @@ -0,0 +1,30 @@ +[alembic] +script_location = . +prepend_sys_path = . +file_template = %%(rev)s_%%(slug)s + +[logging] +keys = root + +[loggers] +keys = root + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console +qualname = + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = INFO +formatter = generic + +[formatter_generic] +format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/notifications/migrations/env.py b/plugins/notifications/migrations/env.py new file mode 100644 index 0000000..d02c111 --- /dev/null +++ b/plugins/notifications/migrations/env.py @@ -0,0 +1,12 @@ +"""Alembic env.py for the notifications plugin. + +Thin shim that sets PLUGIN_NAME then delegates to the shared template at +shopdb.plugins.alembic_template, which filters MetaData to only this +plugin's tables and runs Alembic against the Flask app's engine. +""" +import os +os.environ['PLUGIN_NAME'] = 'notifications' + +from shopdb.plugins.alembic_template import run_migrations + +run_migrations() diff --git a/plugins/notifications/migrations/script.py.mako b/plugins/notifications/migrations/script.py.mako new file mode 100644 index 0000000..f230367 --- /dev/null +++ b/plugins/notifications/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${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 = ${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/plugins/notifications/migrations/versions/0001_baseline.py b/plugins/notifications/migrations/versions/0001_baseline.py new file mode 100644 index 0000000..6d44b6f --- /dev/null +++ b/plugins/notifications/migrations/versions/0001_baseline.py @@ -0,0 +1,27 @@ +"""notifications plugin: baseline schema + +Creates every table owned by the notifications plugin per +shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions +are derived from the SQLAlchemy models at migration runtime so this stays +in lockstep with the model layer without duplication. + +Revision ID: 0001_baseline_notifications +Revises: +Create Date: 2026-05-30 + +""" +from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables + + +revision = '0001_baseline_notifications' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + create_plugin_tables('notifications') + + +def downgrade(): + drop_plugin_tables('notifications') diff --git a/plugins/printers/migrations/alembic.ini b/plugins/printers/migrations/alembic.ini new file mode 100644 index 0000000..0775ad4 --- /dev/null +++ b/plugins/printers/migrations/alembic.ini @@ -0,0 +1,30 @@ +[alembic] +script_location = . +prepend_sys_path = . +file_template = %%(rev)s_%%(slug)s + +[logging] +keys = root + +[loggers] +keys = root + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console +qualname = + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = INFO +formatter = generic + +[formatter_generic] +format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/printers/migrations/env.py b/plugins/printers/migrations/env.py new file mode 100644 index 0000000..0cec79d --- /dev/null +++ b/plugins/printers/migrations/env.py @@ -0,0 +1,12 @@ +"""Alembic env.py for the printers plugin. + +Thin shim that sets PLUGIN_NAME then delegates to the shared template at +shopdb.plugins.alembic_template, which filters MetaData to only this +plugin's tables and runs Alembic against the Flask app's engine. +""" +import os +os.environ['PLUGIN_NAME'] = 'printers' + +from shopdb.plugins.alembic_template import run_migrations + +run_migrations() diff --git a/plugins/printers/migrations/script.py.mako b/plugins/printers/migrations/script.py.mako new file mode 100644 index 0000000..f230367 --- /dev/null +++ b/plugins/printers/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${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 = ${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/plugins/printers/migrations/versions/0001_baseline.py b/plugins/printers/migrations/versions/0001_baseline.py new file mode 100644 index 0000000..8247960 --- /dev/null +++ b/plugins/printers/migrations/versions/0001_baseline.py @@ -0,0 +1,27 @@ +"""printers plugin: baseline schema + +Creates every table owned by the printers plugin per +shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions +are derived from the SQLAlchemy models at migration runtime so this stays +in lockstep with the model layer without duplication. + +Revision ID: 0001_baseline_printers +Revises: +Create Date: 2026-05-30 + +""" +from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables + + +revision = '0001_baseline_printers' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + create_plugin_tables('printers') + + +def downgrade(): + drop_plugin_tables('printers') diff --git a/plugins/usb/migrations/alembic.ini b/plugins/usb/migrations/alembic.ini new file mode 100644 index 0000000..0775ad4 --- /dev/null +++ b/plugins/usb/migrations/alembic.ini @@ -0,0 +1,30 @@ +[alembic] +script_location = . +prepend_sys_path = . +file_template = %%(rev)s_%%(slug)s + +[logging] +keys = root + +[loggers] +keys = root + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console +qualname = + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = INFO +formatter = generic + +[formatter_generic] +format = %%(levelname)-5.5s [%%(name)s] %%(message)s diff --git a/plugins/usb/migrations/env.py b/plugins/usb/migrations/env.py new file mode 100644 index 0000000..7599521 --- /dev/null +++ b/plugins/usb/migrations/env.py @@ -0,0 +1,12 @@ +"""Alembic env.py for the usb plugin. + +Thin shim that sets PLUGIN_NAME then delegates to the shared template at +shopdb.plugins.alembic_template, which filters MetaData to only this +plugin's tables and runs Alembic against the Flask app's engine. +""" +import os +os.environ['PLUGIN_NAME'] = 'usb' + +from shopdb.plugins.alembic_template import run_migrations + +run_migrations() diff --git a/plugins/usb/migrations/script.py.mako b/plugins/usb/migrations/script.py.mako new file mode 100644 index 0000000..f230367 --- /dev/null +++ b/plugins/usb/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${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 = ${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/plugins/usb/migrations/versions/0001_baseline.py b/plugins/usb/migrations/versions/0001_baseline.py new file mode 100644 index 0000000..6200b1f --- /dev/null +++ b/plugins/usb/migrations/versions/0001_baseline.py @@ -0,0 +1,27 @@ +"""usb plugin: baseline schema + +Creates every table owned by the usb plugin per +shopdb.plugins.alembic_template.PLUGIN_TABLE_OWNERS. The table definitions +are derived from the SQLAlchemy models at migration runtime so this stays +in lockstep with the model layer without duplication. + +Revision ID: 0001_baseline_usb +Revises: +Create Date: 2026-05-30 + +""" +from shopdb.plugins.alembic_template import create_plugin_tables, drop_plugin_tables + + +revision = '0001_baseline_usb' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + create_plugin_tables('usb') + + +def downgrade(): + drop_plugin_tables('usb') diff --git a/shopdb/plugins/__init__.py b/shopdb/plugins/__init__.py index 9c39836..cf432c7 100644 --- a/shopdb/plugins/__init__.py +++ b/shopdb/plugins/__init__.py @@ -71,6 +71,34 @@ class PluginManager: for name, plugin in plugins.items(): self._register_plugin_components(plugin) + def upgrade_all_plugins(self) -> Dict[str, str]: + """Run pending Alembic migrations for every loaded plugin. + + Returns {plugin_name: 'ok'|'no-migrations'|}. Skips + plugins with no migrations/ directory. Use from the CLI + (`flask plugin upgrade-all`) on a fresh deploy after the core + schema is in place; existing deploys that still use db.create_all + can ignore this and continue to do so. + """ + results: Dict[str, str] = {} + if not self.migration_manager: + return results + plugin_names = list(self.registry.list_installed().keys()) \ + if hasattr(self.registry, 'list_installed') else [] + if not plugin_names: + # Fall back to whatever the loader found on disk. + plugin_names = self.loader.discover_plugins() + for name in plugin_names: + if not self.migration_manager.has_pending_migrations(name): + results[name] = 'no-migrations' + continue + try: + ok = self.migration_manager.run_plugin_migrations(name) + results[name] = 'ok' if ok else 'failed' + except Exception as ex: + results[name] = f'error: {ex}' + return results + def _register_plugin_components(self, plugin: BasePlugin) -> None: """Register plugin's blueprint, models, CLI commands, etc.""" # Register blueprint diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py new file mode 100644 index 0000000..c73f8d5 --- /dev/null +++ b/shopdb/plugins/alembic_template.py @@ -0,0 +1,146 @@ +"""Shared Alembic env.py logic for bundled plugins. + +Each bundled plugin (computers, equipment, network, notifications, printers, +usb) has a `migrations/env.py` that does the minimum: + + import os + os.environ['PLUGIN_NAME'] = 'computers' + from shopdb.plugins.alembic_template import run_migrations + run_migrations() + +This module wires the plugin's models into a MetaData object filtered to +only the tables that belong to that plugin, then runs Alembic in either +offline or online mode against the Flask app's configured engine. + +Plugin tables must be importable via `plugins..models`. Plugins +register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the +filter is explicit (avoids depending on import-side-effect global state). +""" +from __future__ import annotations + +import importlib +import logging +import os +from typing import Iterable + +from alembic import context +from sqlalchemy import MetaData, pool + +logger = logging.getLogger('alembic.env.plugin') + +# Explicit table-ownership map. Adding tables to a plugin requires updating +# this dict so the per-plugin migration knows which tables to include. +PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { + 'computers': ('computertypes', 'computers', 'computerinstalledapps'), + 'equipment': ('equipmenttypes', 'equipment'), + 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), + 'notifications': ('notificationtypes', 'notifications'), + 'printers': ('printertypes', 'printers', 'printerdata'), + 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), +} + + +def _get_plugin_metadata(plugin_name: str) -> MetaData: + """Import the plugin's models and return a MetaData containing only its + declared tables (filtered via PLUGIN_TABLE_OWNERS).""" + owned = set(PLUGIN_TABLE_OWNERS.get(plugin_name, ())) + if not owned: + raise RuntimeError( + f"PLUGIN_TABLE_OWNERS has no entry for plugin '{plugin_name}'. " + f"Update shopdb/plugins/alembic_template.py." + ) + + # Importing models attaches them to the global db.metadata. + importlib.import_module(f'plugins.{plugin_name}.models') + from shopdb.extensions import db + full = db.metadata + + plugin_md = MetaData() + for table in list(full.tables.values()): + if table.name in owned: + table.to_metadata(plugin_md) + return plugin_md + + +def create_plugin_tables(plugin_name: str): + """Emit CreateTable DDL for every table this plugin owns. Idempotent + via Alembic's batch_op.create_table behavior (raises if exists; the + baseline migration is meant to run against an empty schema). + + Called from each plugin's 0001_baseline.py upgrade() so the table + definitions stay sourced from the SQLAlchemy models rather than being + duplicated in handwritten Alembic ops. + """ + from alembic import op + from sqlalchemy.schema import CreateTable + + md = _get_plugin_metadata(plugin_name) + bind = op.get_bind() + # Sort by FK dependency so parent tables are created first. + for table in md.sorted_tables: + op.execute(str(CreateTable(table).compile(dialect=bind.dialect))) + + +def drop_plugin_tables(plugin_name: str): + """Mirror of create_plugin_tables for downgrade(). Drops in reverse FK + order.""" + from alembic import op + + md = _get_plugin_metadata(plugin_name) + for table in reversed(md.sorted_tables): + op.execute(f'DROP TABLE IF EXISTS "{table.name}"') + + +def run_migrations(): + """Entry point called by each plugin's migrations/env.py.""" + plugin_name = os.environ.get('PLUGIN_NAME') + if not plugin_name: + raise RuntimeError("PLUGIN_NAME env var must be set before run_migrations()") + + config = context.config + target_metadata = _get_plugin_metadata(plugin_name) + + # Per-plugin version table so each plugin's chain is independent of core + # Alembic's alembic_version table. + version_table = f'alembic_version_{plugin_name}' + + db_url = config.get_main_option('sqlalchemy.url') + if not db_url: + # Pull from the Flask app config if running inside an app context + # (e.g. via flask plugin migrate ). + try: + from flask import current_app + db_url = current_app.config['SQLALCHEMY_DATABASE_URI'] + config.set_main_option('sqlalchemy.url', db_url.replace('%', '%%')) + except Exception as ex: + raise RuntimeError( + "sqlalchemy.url not set and no Flask app context available. " + f"Original error: {ex}" + ) + + if context.is_offline_mode(): + context.configure( + url=db_url, + target_metadata=target_metadata, + literal_binds=True, + version_table=version_table, + include_schemas=False, + ) + with context.begin_transaction(): + context.run_migrations() + else: + from sqlalchemy import engine_from_config + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix='sqlalchemy.', + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + version_table=version_table, + include_schemas=False, + ) + with context.begin_transaction(): + context.run_migrations() diff --git a/shopdb/plugins/cli.py b/shopdb/plugins/cli.py index cc966cc..4df1a28 100644 --- a/shopdb/plugins/cli.py +++ b/shopdb/plugins/cli.py @@ -241,3 +241,30 @@ def migrate_plugin(name: str, revision: str): else: click.echo(click.style("Migration failed", fg='red')) raise SystemExit(1) + + +@plugin_cli.command('upgrade-all') +@with_appcontext +def upgrade_all_plugins(): + """Run pending migrations for every loaded plugin. + + Idempotent. Use on a fresh deploy after core schema is in place, + instead of (or alongside) `db-utils create-all`. Existing deployments + that still bootstrap plugin tables via db.create_all can ignore this + command until ready to move a plugin onto its Alembic version chain. + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + results = pm.upgrade_all_plugins() + if not results: + click.echo("No plugins discovered.") + return + for name, status in sorted(results.items()): + if status == 'ok': + click.echo(click.style(f" {name:20} ok", fg='green')) + elif status == 'no-migrations': + click.echo(click.style(f" {name:20} no migrations", fg='yellow')) + else: + click.echo(click.style(f" {name:20} {status}", fg='red')) diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py new file mode 100644 index 0000000..418dd26 --- /dev/null +++ b/tests/test_plugin_migrations.py @@ -0,0 +1,51 @@ +"""Per-plugin Alembic chain wiring tests. + +Pins the bundled-plugin migration setup so a future refactor that breaks +the scaffolding fails fast with a clear test rather than a confusing +runtime error on a fresh deploy. +""" +from pathlib import Path + +import pytest + +from shopdb.plugins.alembic_template import ( + PLUGIN_TABLE_OWNERS, + _get_plugin_metadata, +) + + +BUNDLED_PLUGINS = ('computers', 'equipment', 'network', 'notifications', 'printers', 'usb') + + +@pytest.mark.parametrize('plugin', BUNDLED_PLUGINS) +def test_bundled_plugin_has_table_owner_entry(plugin): + """Every bundled plugin appears in PLUGIN_TABLE_OWNERS with at least + one table; otherwise its baseline migration would be a no-op.""" + assert plugin in PLUGIN_TABLE_OWNERS + assert len(PLUGIN_TABLE_OWNERS[plugin]) > 0 + + +@pytest.mark.parametrize('plugin', BUNDLED_PLUGINS) +def test_bundled_plugin_has_migrations_dir(plugin): + """Each bundled plugin has the on-disk Alembic scaffolding.""" + root = Path(__file__).resolve().parent.parent / 'plugins' / plugin / 'migrations' + assert (root / 'env.py').is_file(), f"{plugin}/migrations/env.py missing" + assert (root / 'alembic.ini').is_file(), f"{plugin}/migrations/alembic.ini missing" + assert (root / 'script.py.mako').is_file(), f"{plugin}/migrations/script.py.mako missing" + versions = root / 'versions' + assert versions.is_dir(), f"{plugin}/migrations/versions missing" + baseline = versions / '0001_baseline.py' + assert baseline.is_file(), f"{plugin}/migrations/versions/0001_baseline.py missing" + + +@pytest.mark.parametrize('plugin', BUNDLED_PLUGINS) +def test_plugin_metadata_has_all_owned_tables(plugin, app): + """The MetaData filtered to a plugin's owned tables actually contains + every table named in PLUGIN_TABLE_OWNERS. Catches drift between the + template's table list and what the models declare.""" + with app.app_context(): + md = _get_plugin_metadata(plugin) + owned = set(PLUGIN_TABLE_OWNERS[plugin]) + present = set(md.tables.keys()) + missing = owned - present + assert not missing, f"Plugin {plugin}: tables in PLUGIN_TABLE_OWNERS not in metadata: {missing}"