Add glue layer support: MenuNode templates, factory registration, migration

- 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 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-11 23:37:35 +00:00
parent e1e6a7a98b
commit 2c4ab9e3c8
7 changed files with 129 additions and 26 deletions

View File

@@ -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:

View File

@@ -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')

View File

@@ -12,21 +12,21 @@
</div>
{# Hidden field for selected post ID - outside form for JS access #}
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.post_id if menu_item else '' }}" />
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.container_id if menu_item else '' }}" />
{# Selected page display #}
{% if menu_item %}
<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">
{% if menu_item.post.feature_image %}
<img src="{{ menu_item.post.feature_image }}"
alt="{{ menu_item.post.title }}"
{% if menu_item.feature_image %}
<img src="{{ menu_item.feature_image }}"
alt="{{ menu_item.label }}"
class="w-10 h-10 rounded-full object-cover" />
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-200"></div>
{% endif %}
<div class="flex-1">
<div class="font-medium">{{ menu_item.post.title }}</div>
<div class="text-xs text-stone-500">{{ menu_item.post.slug }}</div>
<div class="font-medium">{{ menu_item.label }}</div>
<div class="text-xs text-stone-500">{{ menu_item.slug }}</div>
</div>
</div>
{% else %}

View File

@@ -9,9 +9,9 @@
</div>
{# Page image #}
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-12 h-12 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"></div>
@@ -19,8 +19,8 @@
{# Page title #}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ item.post.title }}</div>
<div class="text-xs text-stone-500 truncate">{{ item.post.slug }}</div>
<div class="font-medium truncate">{{ item.label }}</div>
<div class="text-xs text-stone-500 truncate">{{ item.slug }}</div>
</div>
{# 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"

View File

@@ -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 + '/')) %}
<a
href="{{ _href }}"
{% if item.post.slug not in _app_slugs %}
hx-get="/{{ item.post.slug }}/"
{% if item.slug not in _app_slugs %}
hx-get="/{{ item.slug }}/"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
@@ -16,14 +16,14 @@
{% endif %}
class="{{styles.nav_button}}"
>
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<span>{{ item.post.title }}</span>
<span>{{ item.label }}</span>
</a>
{% endcall %}
</div>

View File

@@ -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 + '/')) %}
<a
href="{{ _href }}"
class="{{styles.nav_button_less_pad}}"
>
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<span>{{ item.post.title }}</span>
<span>{{ item.label }}</span>
</a>
{% endcall %}
</div>

View File

@@ -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()