Spaces:
Running
Running
Ashraf Al-Kassem Claude Sonnet 4.6 commited on
Commit ·
e3db935
1
Parent(s): 73428ba
Fix Alembic migrations to be idempotent against create_all race
Browse filesRoot cause: SQLModel.metadata.create_all in startup scripts creates
tables ahead of Alembic, causing subsequent migration runs to fail
with "table already exists" or wrong table name errors.
Fixes:
- Mission 38: make user_identity CREATE TABLE idempotent, fix table
name oauth_state → oauthstate (SQLModel uses lowercase class name),
make purpose column addition idempotent
- Mission M-B: make qualification_status/intent ADD COLUMN idempotent
Both migrations now check column/table existence before creating,
allowing alembic upgrade head to run safely on any DB state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
backend/alembic/versions/o5p6q7r8s9t0_mission_38_social_login.py
CHANGED
|
@@ -5,7 +5,7 @@ Revises: n4o5p6q7r8s9
|
|
| 5 |
Create Date: 2026-03-21 00:00:00.000000
|
| 6 |
|
| 7 |
Creates user_identity table for social login (Facebook, TikTok).
|
| 8 |
-
Adds purpose column to
|
| 9 |
"""
|
| 10 |
from alembic import op
|
| 11 |
import sqlalchemy as sa
|
|
@@ -17,35 +17,46 @@ depends_on = None
|
|
| 17 |
|
| 18 |
|
| 19 |
def upgrade() -> None:
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
def downgrade() -> None:
|
| 48 |
-
op.drop_column("
|
| 49 |
op.drop_index("idx_user_identity_provider", table_name="user_identity")
|
| 50 |
op.drop_index("idx_user_identity_user_id", table_name="user_identity")
|
| 51 |
op.drop_table("user_identity")
|
|
|
|
| 5 |
Create Date: 2026-03-21 00:00:00.000000
|
| 6 |
|
| 7 |
Creates user_identity table for social login (Facebook, TikTok).
|
| 8 |
+
Adds purpose column to oauthstate for flow disambiguation.
|
| 9 |
"""
|
| 10 |
from alembic import op
|
| 11 |
import sqlalchemy as sa
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def upgrade() -> None:
|
| 20 |
+
bind = op.get_bind()
|
| 21 |
+
inspector = sa.inspect(bind)
|
| 22 |
+
existing_tables = inspector.get_table_names()
|
| 23 |
+
|
| 24 |
+
# --- user_identity table (idempotent: create_all may have already created it) ---
|
| 25 |
+
if "user_identity" not in existing_tables:
|
| 26 |
+
op.create_table(
|
| 27 |
+
"user_identity",
|
| 28 |
+
sa.Column("id", sa.Uuid(), nullable=False),
|
| 29 |
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 30 |
+
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
| 31 |
+
sa.Column("user_id", sa.Uuid(), nullable=False),
|
| 32 |
+
sa.Column("provider_code", sa.String(), nullable=False),
|
| 33 |
+
sa.Column("provider_user_id", sa.String(), nullable=False),
|
| 34 |
+
sa.Column("provider_email", sa.String(), nullable=True),
|
| 35 |
+
sa.Column("provider_name", sa.String(), nullable=True),
|
| 36 |
+
sa.Column("provider_avatar_url", sa.String(), nullable=True),
|
| 37 |
+
sa.Column("metadata_json", sa.String(), nullable=True),
|
| 38 |
+
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name="fk_user_identity_user_id"),
|
| 39 |
+
sa.PrimaryKeyConstraint("id"),
|
| 40 |
+
sa.UniqueConstraint("provider_code", "provider_user_id", name="uq_user_identity_provider"),
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
existing_indexes = {idx["name"] for idx in inspector.get_indexes("user_identity")}
|
| 44 |
+
if "idx_user_identity_user_id" not in existing_indexes:
|
| 45 |
+
op.create_index("idx_user_identity_user_id", "user_identity", ["user_id"])
|
| 46 |
+
if "idx_user_identity_provider" not in existing_indexes:
|
| 47 |
+
op.create_index("idx_user_identity_provider", "user_identity", ["provider_code", "provider_user_id"])
|
| 48 |
+
|
| 49 |
+
# --- oauthstate: add purpose column (idempotent) ---
|
| 50 |
+
existing_cols = {c["name"] for c in inspector.get_columns("oauthstate")}
|
| 51 |
+
if "purpose" not in existing_cols:
|
| 52 |
+
op.add_column(
|
| 53 |
+
"oauthstate",
|
| 54 |
+
sa.Column("purpose", sa.String(), nullable=True, server_default="integration"),
|
| 55 |
+
)
|
| 56 |
|
| 57 |
|
| 58 |
def downgrade() -> None:
|
| 59 |
+
op.drop_column("oauthstate", "purpose")
|
| 60 |
op.drop_index("idx_user_identity_provider", table_name="user_identity")
|
| 61 |
op.drop_index("idx_user_identity_user_id", table_name="user_identity")
|
| 62 |
op.drop_table("user_identity")
|
backend/alembic/versions/q7r8s9t0u1v2_mission_mb_contact_qualification.py
CHANGED
|
@@ -16,8 +16,13 @@ depends_on = None
|
|
| 16 |
|
| 17 |
|
| 18 |
def upgrade() -> None:
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def downgrade() -> None:
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def upgrade() -> None:
|
| 19 |
+
bind = op.get_bind()
|
| 20 |
+
inspector = sa.inspect(bind)
|
| 21 |
+
existing_cols = {c["name"] for c in inspector.get_columns("contact")}
|
| 22 |
+
if "qualification_status" not in existing_cols:
|
| 23 |
+
op.add_column("contact", sa.Column("qualification_status", sa.String(), nullable=True))
|
| 24 |
+
if "intent" not in existing_cols:
|
| 25 |
+
op.add_column("contact", sa.Column("intent", sa.String(), nullable=True))
|
| 26 |
|
| 27 |
|
| 28 |
def downgrade() -> None:
|
backend/app/api/v1/test_chat.py
CHANGED
|
@@ -1,12 +1,9 @@
|
|
| 1 |
-
import logging
|
| 2 |
from typing import Any, Dict
|
| 3 |
from uuid import UUID
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from sqlmodel import select, desc
|
| 7 |
|
| 8 |
-
logger = logging.getLogger(__name__)
|
| 9 |
-
|
| 10 |
from app.api import deps
|
| 11 |
from app.core.db import get_db
|
| 12 |
from app.models.models import Workspace, Conversation, Message, Contact
|
|
@@ -25,32 +22,28 @@ async def create_test_session(
|
|
| 25 |
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 26 |
) -> Any:
|
| 27 |
"""Create a new test conversation session."""
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
contact = Contact(
|
| 35 |
-
workspace_id=workspace.id,
|
| 36 |
-
external_id="test-contact",
|
| 37 |
-
first_name="Test",
|
| 38 |
-
last_name="User"
|
| 39 |
-
)
|
| 40 |
-
db.add(contact)
|
| 41 |
-
await db.flush()
|
| 42 |
-
|
| 43 |
-
conversation = Conversation(
|
| 44 |
workspace_id=workspace.id,
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
)
|
| 47 |
-
db.add(
|
| 48 |
-
await db.
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
@router.post("/sessions/{session_id}/messages", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_RUNTIME_ENGINE, "write")), Depends(require_entitlement("runtime_engine"))])
|
|
|
|
|
|
|
| 1 |
from typing import Any, Dict
|
| 2 |
from uuid import UUID
|
| 3 |
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
from sqlmodel import select, desc
|
| 6 |
|
|
|
|
|
|
|
| 7 |
from app.api import deps
|
| 8 |
from app.core.db import get_db
|
| 9 |
from app.models.models import Workspace, Conversation, Message, Contact
|
|
|
|
| 22 |
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 23 |
) -> Any:
|
| 24 |
"""Create a new test conversation session."""
|
| 25 |
+
result = await db.execute(
|
| 26 |
+
select(Contact).where(Contact.workspace_id == workspace.id, Contact.external_id == "test-contact")
|
| 27 |
+
)
|
| 28 |
+
contact = result.scalars().first()
|
| 29 |
+
if not contact:
|
| 30 |
+
contact = Contact(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
workspace_id=workspace.id,
|
| 32 |
+
external_id="test-contact",
|
| 33 |
+
first_name="Test",
|
| 34 |
+
last_name="User"
|
| 35 |
)
|
| 36 |
+
db.add(contact)
|
| 37 |
+
await db.flush()
|
| 38 |
+
|
| 39 |
+
conversation = Conversation(
|
| 40 |
+
workspace_id=workspace.id,
|
| 41 |
+
contact_id=contact.id
|
| 42 |
+
)
|
| 43 |
+
db.add(conversation)
|
| 44 |
+
await db.commit()
|
| 45 |
+
await db.refresh(conversation)
|
| 46 |
+
return wrap_data({"session_id": conversation.id})
|
| 47 |
|
| 48 |
|
| 49 |
@router.post("/sessions/{session_id}/messages", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_RUNTIME_ENGINE, "write")), Depends(require_entitlement("runtime_engine"))])
|