From 2c4ab9e3c8f93b73bde32008b487168a11ed451c Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 11 Feb 2026 23:37:35 +0000 Subject: [PATCH] Add glue layer support: MenuNode templates, factory registration, migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Templates: item.post.X → item.X (MenuNode has label/slug/feature_image directly) - factory.py: add glue.models to import loop + register_glue_handlers() at startup - alembic env.py: add glue.models to import loop - New migration: container_relations + menu_nodes tables with backfill from existing data Co-Authored-By: Claude Opus 4.6 --- alembic/env.py | 2 +- .../i9g7d3e5f6_add_glue_layer_tables.py | 98 +++++++++++++++++++ .../templates/_types/menu_items/_form.html | 12 +-- .../templates/_types/menu_items/_list.html | 12 +-- .../templates/_types/menu_items/_nav_oob.html | 14 +-- browser/templates/_types/root/_nav.html | 10 +- infrastructure/factory.py | 7 +- 7 files changed, 129 insertions(+), 26 deletions(-) create mode 100644 alembic/versions/i9g7d3e5f6_add_glue_layer_tables.py diff --git a/alembic/env.py b/alembic/env.py index c57a671..caef2b1 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -19,7 +19,7 @@ from shared.db.base import Base # Import ALL models so Base.metadata sees every table import shared.models # noqa: F401 User, KV, MagicLink, MenuItem, Ghost* -for _mod in ("blog.models", "market.models", "cart.models", "events.models"): +for _mod in ("blog.models", "market.models", "cart.models", "events.models", "glue.models"): try: __import__(_mod) except ImportError: diff --git a/alembic/versions/i9g7d3e5f6_add_glue_layer_tables.py b/alembic/versions/i9g7d3e5f6_add_glue_layer_tables.py new file mode 100644 index 0000000..781f613 --- /dev/null +++ b/alembic/versions/i9g7d3e5f6_add_glue_layer_tables.py @@ -0,0 +1,98 @@ +"""add glue layer tables (container_relations + menu_nodes) + +Revision ID: i9g7d3e5f6 +Revises: h8f6c2d4e5a9 +Create Date: 2026-02-11 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'i9g7d3e5f6' +down_revision = 'h8f6c2d4e5a9' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- container_relations --- + op.create_table( + 'container_relations', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('parent_type', sa.String(32), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=False), + sa.Column('child_type', sa.String(32), nullable=False), + sa.Column('child_id', sa.Integer(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('label', sa.String(255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint( + 'parent_type', 'parent_id', 'child_type', 'child_id', + name='uq_container_relations_parent_child', + ), + ) + op.create_index('ix_container_relations_parent', 'container_relations', ['parent_type', 'parent_id']) + op.create_index('ix_container_relations_child', 'container_relations', ['child_type', 'child_id']) + + # --- menu_nodes --- + op.create_table( + 'menu_nodes', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('container_type', sa.String(32), nullable=False), + sa.Column('container_id', sa.Integer(), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('depth', sa.Integer(), nullable=False, server_default='0'), + sa.Column('label', sa.String(255), nullable=False), + sa.Column('slug', sa.String(255), nullable=True), + sa.Column('href', sa.String(1024), nullable=True), + sa.Column('icon', sa.String(64), nullable=True), + sa.Column('feature_image', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['parent_id'], ['menu_nodes.id'], ondelete='SET NULL'), + ) + op.create_index('ix_menu_nodes_container', 'menu_nodes', ['container_type', 'container_id']) + op.create_index('ix_menu_nodes_parent_id', 'menu_nodes', ['parent_id']) + + # --- Backfill container_relations from existing container-pattern tables --- + op.execute(""" + INSERT INTO container_relations (parent_type, parent_id, child_type, child_id, sort_order) + SELECT 'page', container_id, 'calendar', id, 0 + FROM calendars + WHERE deleted_at IS NULL AND container_type = 'page' + """) + op.execute(""" + INSERT INTO container_relations (parent_type, parent_id, child_type, child_id, sort_order) + SELECT 'page', container_id, 'market', id, 0 + FROM market_places + WHERE deleted_at IS NULL AND container_type = 'page' + """) + op.execute(""" + INSERT INTO container_relations (parent_type, parent_id, child_type, child_id, sort_order) + SELECT 'page', container_id, 'page_config', id, 0 + FROM page_configs + WHERE deleted_at IS NULL AND container_type = 'page' + """) + + # --- Backfill menu_nodes from existing menu_items + posts --- + op.execute(""" + INSERT INTO menu_nodes (container_type, container_id, label, slug, feature_image, sort_order) + SELECT 'page', mi.post_id, p.title, p.slug, p.feature_image, mi.sort_order + FROM menu_items mi + JOIN posts p ON mi.post_id = p.id + WHERE mi.deleted_at IS NULL + """) + + +def downgrade() -> None: + op.drop_index('ix_menu_nodes_parent_id', table_name='menu_nodes') + op.drop_index('ix_menu_nodes_container', table_name='menu_nodes') + op.drop_table('menu_nodes') + op.drop_index('ix_container_relations_child', table_name='container_relations') + op.drop_index('ix_container_relations_parent', table_name='container_relations') + op.drop_table('container_relations') diff --git a/browser/templates/_types/menu_items/_form.html b/browser/templates/_types/menu_items/_form.html index 15bb404..8eed1c0 100644 --- a/browser/templates/_types/menu_items/_form.html +++ b/browser/templates/_types/menu_items/_form.html @@ -12,21 +12,21 @@ {# Hidden field for selected post ID - outside form for JS access #} - + {# Selected page display #} {% if menu_item %}
- {% if menu_item.post.feature_image %} - {{ menu_item.post.title }} {% else %}
{% endif %}
-
{{ menu_item.post.title }}
-
{{ menu_item.post.slug }}
+
{{ menu_item.label }}
+
{{ menu_item.slug }}
{% else %} diff --git a/browser/templates/_types/menu_items/_list.html b/browser/templates/_types/menu_items/_list.html index 70f676c..3892f07 100644 --- a/browser/templates/_types/menu_items/_list.html +++ b/browser/templates/_types/menu_items/_list.html @@ -9,9 +9,9 @@ {# Page image #} - {% if item.post.feature_image %} - {{ item.post.title }} {% else %}
@@ -19,8 +19,8 @@ {# Page title #}
-
{{ item.post.title }}
-
{{ item.post.slug }}
+
{{ item.label }}
+
{{ item.slug }}
{# Sort order #} @@ -42,7 +42,7 @@ type="button" data-confirm data-confirm-title="Delete menu item?" - data-confirm-text="Remove {{ item.post.title }} from the menu?" + data-confirm-text="Remove {{ item.label }} from the menu?" data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete" data-confirm-cancel-text="Cancel" diff --git a/browser/templates/_types/menu_items/_nav_oob.html b/browser/templates/_types/menu_items/_nav_oob.html index f8bdd5c..f3ef989 100644 --- a/browser/templates/_types/menu_items/_nav_oob.html +++ b/browser/templates/_types/menu_items/_nav_oob.html @@ -4,11 +4,11 @@ hx-swap-oob="outerHTML"> {% from 'macros/scrolling_menu.html' import scrolling_menu with context %} {% call(item) scrolling_menu('menu-items-container', menu_items) %} - {% set _href = _app_slugs.get(item.post.slug, coop_url('/' + item.post.slug + '/')) %} + {% set _href = _app_slugs.get(item.slug, coop_url('/' + item.slug + '/')) %} - {% if item.post.feature_image %} - {{ item.post.title }} {% else %}
{% endif %} - {{ item.post.title }} + {{ item.label }}
{% endcall %} diff --git a/browser/templates/_types/root/_nav.html b/browser/templates/_types/root/_nav.html index 5e61e58..de26699 100644 --- a/browser/templates/_types/root/_nav.html +++ b/browser/templates/_types/root/_nav.html @@ -3,19 +3,19 @@ id="menu-items-nav-wrapper"> {% from 'macros/scrolling_menu.html' import scrolling_menu with context %} {% call(item) scrolling_menu('menu-items-container', menu_items) %} - {% set _href = _app_slugs.get(item.post.slug, coop_url('/' + item.post.slug + '/')) %} + {% set _href = _app_slugs.get(item.slug, coop_url('/' + item.slug + '/')) %} - {% if item.post.feature_image %} - {{ item.post.title }} {% else %}
{% endif %} - {{ item.post.title }} + {{ item.label }}
{% endcall %} diff --git a/infrastructure/factory.py b/infrastructure/factory.py index a1814eb..e6ce3d9 100644 --- a/infrastructure/factory.py +++ b/infrastructure/factory.py @@ -11,7 +11,7 @@ from shared.config import init_config, config, pretty from shared.models import KV # ensure shared models imported # Register all app model classes with SQLAlchemy so cross-domain # relationship() string references resolve correctly. -for _mod in ("blog.models", "market.models", "cart.models", "events.models"): +for _mod in ("blog.models", "market.models", "cart.models", "events.models", "glue.models"): try: __import__(_mod) except ImportError: @@ -144,6 +144,11 @@ def create_base_app( # --- startup --- @app.before_serving async def _startup(): + try: + from glue.setup import register_glue_handlers + register_glue_handlers() + except ImportError: + pass # glue submodule not present in this build context await init_config() print(pretty()) await _event_processor.start()