Phase 7B: per-plugin Alembic chains for bundled plugins
Each of the six bundled plugins (computers, equipment, network,
notifications, printers, usb) now has its own Alembic chain with a
baseline migration. Sister sites adopting one of these plugins can
manage its schema via `flask plugin migrate <name>` instead of relying
on db.create_all to bootstrap everything.
Existing single-site deploys that bootstrap via db.create_all continue
to work unchanged. The chains coexist; the bootstrap path stays the
operator's choice.
Framework
- shopdb/plugins/alembic_template.py: shared env.py logic + helpers.
PLUGIN_TABLE_OWNERS pins which tables belong to which plugin (explicit
registry, not import-side-effect). _get_plugin_metadata filters
db.metadata to only the named plugin's tables. create_plugin_tables /
drop_plugin_tables emit DDL via SQLAlchemy CreateTable so the table
definitions stay sourced from the models, not duplicated.
- shopdb/plugins/__init__.py: PluginManager.upgrade_all_plugins() runs
pending migrations across every discovered plugin and returns a status
dict. Idempotent (Alembic skips applied revisions).
CLI
- `flask plugin upgrade-all` runs pending migrations for every plugin.
Used on a fresh deploy after the core schema is in place.
Per-plugin scaffolding
- plugins/{computers,equipment,network,notifications,printers,usb}/
migrations/{alembic.ini, env.py, script.py.mako, versions/0001_baseline.py}
- Each env.py is a 5-line shim that sets PLUGIN_NAME and delegates to
the shared template. Each 0001_baseline calls create_plugin_tables(name)
/ drop_plugin_tables(name); no duplication of column definitions.
Tests
- tests/test_plugin_migrations.py (18 cases): every bundled plugin has
an entry in PLUGIN_TABLE_OWNERS, has the on-disk Alembic scaffolding,
and the filtered MetaData contains every owned table (catches drift
between the template's table list and what the models declare).
- 129 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
plugins/computers/migrations/alembic.ini
Normal file
30
plugins/computers/migrations/alembic.ini
Normal file
@@ -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
|
||||||
12
plugins/computers/migrations/env.py
Normal file
12
plugins/computers/migrations/env.py
Normal file
@@ -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()
|
||||||
23
plugins/computers/migrations/script.py.mako
Normal file
23
plugins/computers/migrations/script.py.mako
Normal file
@@ -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"}
|
||||||
27
plugins/computers/migrations/versions/0001_baseline.py
Normal file
27
plugins/computers/migrations/versions/0001_baseline.py
Normal file
@@ -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')
|
||||||
30
plugins/equipment/migrations/alembic.ini
Normal file
30
plugins/equipment/migrations/alembic.ini
Normal file
@@ -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
|
||||||
12
plugins/equipment/migrations/env.py
Normal file
12
plugins/equipment/migrations/env.py
Normal file
@@ -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()
|
||||||
23
plugins/equipment/migrations/script.py.mako
Normal file
23
plugins/equipment/migrations/script.py.mako
Normal file
@@ -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"}
|
||||||
27
plugins/equipment/migrations/versions/0001_baseline.py
Normal file
27
plugins/equipment/migrations/versions/0001_baseline.py
Normal file
@@ -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')
|
||||||
30
plugins/network/migrations/alembic.ini
Normal file
30
plugins/network/migrations/alembic.ini
Normal file
@@ -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
|
||||||
12
plugins/network/migrations/env.py
Normal file
12
plugins/network/migrations/env.py
Normal file
@@ -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()
|
||||||
23
plugins/network/migrations/script.py.mako
Normal file
23
plugins/network/migrations/script.py.mako
Normal file
@@ -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"}
|
||||||
27
plugins/network/migrations/versions/0001_baseline.py
Normal file
27
plugins/network/migrations/versions/0001_baseline.py
Normal file
@@ -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')
|
||||||
30
plugins/notifications/migrations/alembic.ini
Normal file
30
plugins/notifications/migrations/alembic.ini
Normal file
@@ -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
|
||||||
12
plugins/notifications/migrations/env.py
Normal file
12
plugins/notifications/migrations/env.py
Normal file
@@ -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()
|
||||||
23
plugins/notifications/migrations/script.py.mako
Normal file
23
plugins/notifications/migrations/script.py.mako
Normal file
@@ -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"}
|
||||||
27
plugins/notifications/migrations/versions/0001_baseline.py
Normal file
27
plugins/notifications/migrations/versions/0001_baseline.py
Normal file
@@ -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')
|
||||||
30
plugins/printers/migrations/alembic.ini
Normal file
30
plugins/printers/migrations/alembic.ini
Normal file
@@ -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
|
||||||
12
plugins/printers/migrations/env.py
Normal file
12
plugins/printers/migrations/env.py
Normal file
@@ -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()
|
||||||
23
plugins/printers/migrations/script.py.mako
Normal file
23
plugins/printers/migrations/script.py.mako
Normal file
@@ -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"}
|
||||||
27
plugins/printers/migrations/versions/0001_baseline.py
Normal file
27
plugins/printers/migrations/versions/0001_baseline.py
Normal file
@@ -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')
|
||||||
30
plugins/usb/migrations/alembic.ini
Normal file
30
plugins/usb/migrations/alembic.ini
Normal file
@@ -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
|
||||||
12
plugins/usb/migrations/env.py
Normal file
12
plugins/usb/migrations/env.py
Normal file
@@ -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()
|
||||||
23
plugins/usb/migrations/script.py.mako
Normal file
23
plugins/usb/migrations/script.py.mako
Normal file
@@ -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"}
|
||||||
27
plugins/usb/migrations/versions/0001_baseline.py
Normal file
27
plugins/usb/migrations/versions/0001_baseline.py
Normal file
@@ -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')
|
||||||
@@ -71,6 +71,34 @@ class PluginManager:
|
|||||||
for name, plugin in plugins.items():
|
for name, plugin in plugins.items():
|
||||||
self._register_plugin_components(plugin)
|
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'|<error str>}. 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:
|
def _register_plugin_components(self, plugin: BasePlugin) -> None:
|
||||||
"""Register plugin's blueprint, models, CLI commands, etc."""
|
"""Register plugin's blueprint, models, CLI commands, etc."""
|
||||||
# Register blueprint
|
# Register blueprint
|
||||||
|
|||||||
146
shopdb/plugins/alembic_template.py
Normal file
146
shopdb/plugins/alembic_template.py
Normal file
@@ -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.<name>.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 <name>).
|
||||||
|
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()
|
||||||
@@ -241,3 +241,30 @@ def migrate_plugin(name: str, revision: str):
|
|||||||
else:
|
else:
|
||||||
click.echo(click.style("Migration failed", fg='red'))
|
click.echo(click.style("Migration failed", fg='red'))
|
||||||
raise SystemExit(1)
|
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'))
|
||||||
|
|||||||
51
tests/test_plugin_migrations.py
Normal file
51
tests/test_plugin_migrations.py
Normal file
@@ -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}"
|
||||||
Reference in New Issue
Block a user