From 600963158e44aa5b73419ad8be19fa414941a150 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Thu, 21 May 2026 12:06:59 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0519_0021_portfolio_tags_data_migration.py | 20 ++++++++++++------- ...520_0022_service_token_unique_index_fix.py | 13 +++++++++--- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/migrations/versions/20260519_0021_portfolio_tags_data_migration.py b/migrations/versions/20260519_0021_portfolio_tags_data_migration.py index 0208417..3b52404 100644 --- a/migrations/versions/20260519_0021_portfolio_tags_data_migration.py +++ b/migrations/versions/20260519_0021_portfolio_tags_data_migration.py @@ -10,6 +10,7 @@ before running this migration in a live environment. from __future__ import annotations +import os import sqlalchemy as sa from alembic import op from sqlalchemy import inspect, text @@ -20,7 +21,8 @@ branch_labels = None depends_on = None # Operator must confirm this user ID exists in the users table before running. -MIGRATION_ADMIN_ID = "migration-admin" +# Can be overridden via MIGRATION_ADMIN_ID environment variable. +MIGRATION_ADMIN_ID = os.environ.get("MIGRATION_ADMIN_ID", "migration-admin") def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool: @@ -28,16 +30,20 @@ def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool: def run_migration(connection, admin_user_id: str) -> None: - """Core migration logic — extracted for testability.""" + """Core migration logic — extracted for testability. + + admin_user_id can be either a UUID (id) or username. + """ + # Try to find user by id (UUID) first, then by username result = connection.execute( - text("SELECT COUNT(*) FROM users WHERE id = :uid"), + text("SELECT id FROM users WHERE id = :uid OR username = :uid LIMIT 1"), {"uid": admin_user_id}, ) - count = result.scalar() - if not count: + user_id = result.scalar() + if not user_id: raise ValueError( f"Migration admin user not found in users table: {admin_user_id}. " - "Set MIGRATION_ADMIN_ID to a valid user ID before running." + "Set MIGRATION_ADMIN_ID to a valid user ID (UUID) or username before running." ) # Migrate distinct (lowercased) tag_values from portfolio_tags → tags master @@ -49,7 +55,7 @@ def run_migration(connection, admin_user_id: str) -> None: GROUP BY LOWER(tag_value) ON CONFLICT (name) DO NOTHING """), - {"admin_id": admin_user_id}, + {"admin_id": user_id}, ) # Fill portfolios.tag_ids from the migrated tags diff --git a/migrations/versions/20260520_0022_service_token_unique_index_fix.py b/migrations/versions/20260520_0022_service_token_unique_index_fix.py index e6b4a17..ade0d55 100644 --- a/migrations/versions/20260520_0022_service_token_unique_index_fix.py +++ b/migrations/versions/20260520_0022_service_token_unique_index_fix.py @@ -25,14 +25,21 @@ def upgrade() -> None: bind = op.get_bind() inspector = inspect(bind) + # First, commit any pending transaction before changing isolation level + bind.commit() + + # Set AUTOCOMMIT mode for CREATE UNIQUE INDEX CONCURRENTLY + autocommit_bind = bind.execution_options(isolation_level="AUTOCOMMIT") + + # Drop old index if it exists (non-partial index) if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"): - bind.execute( + autocommit_bind.execute( __import__("sqlalchemy").text( "DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique" ) ) - - autocommit_bind = bind.execution_options(isolation_level="AUTOCOMMIT") + + # Create new partial unique index with WHERE clause autocommit_bind.execute( __import__("sqlalchemy").text( """ From c4f8718872695d801b4b57a995051bf3844fb4c7 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Thu, 21 May 2026 21:45:39 +0900 Subject: [PATCH 2/2] fix: respect root_path in FastAPI docs --- app/core/config.py | 1 + app/main.py | 1 + tests/test_docs_root_path.py | 24 ++++++++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/test_docs_root_path.py diff --git a/app/core/config.py b/app/core/config.py index b080ce4..f96cf0e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -51,6 +51,7 @@ class Settings(BaseSettings): loki_port: int = Field(default=3100, validation_alias="LOKI_PORT") loki_enabled: bool = Field(default=False, validation_alias="LOKI_ENABLED") credential_encryption_key: str = Field(default="", validation_alias="CREDENTIAL_ENCRYPTION_KEY") + root_path: str = Field(default="", validation_alias="PORTFOLIOHUB_ROOT_PATH") model_config = SettingsConfigDict( env_prefix="PORTFOLIOHUB_", diff --git a/app/main.py b/app/main.py index 9033e08..4595c86 100644 --- a/app/main.py +++ b/app/main.py @@ -63,6 +63,7 @@ def create_app( version=settings.app_version, docs_url="/docs", redoc_url="/redoc", + root_path=settings.root_path, lifespan=lifespan, ) diff --git a/tests/test_docs_root_path.py b/tests/test_docs_root_path.py new file mode 100644 index 0000000..d2da782 --- /dev/null +++ b/tests/test_docs_root_path.py @@ -0,0 +1,24 @@ +from fastapi.testclient import TestClient + +from app.main import create_app +from app.core.config import Settings + + +def test_docs_uses_root_path_for_openapi_url() -> None: + app = create_app(settings_factory=lambda: Settings(root_path="/portfolio-hub")) + + with TestClient(app) as client: + response = client.get("/docs") + + assert response.status_code == 200 + assert "url: '/portfolio-hub/openapi.json'" in response.text + + +def test_openapi_is_served_under_root_path() -> None: + app = create_app(settings_factory=lambda: Settings(root_path="/portfolio-hub")) + + with TestClient(app) as client: + response = client.get("/portfolio-hub/openapi.json") + + assert response.status_code == 200 + assert response.json()["info"]["title"] == "PortfolioHub"