Исправлены ошибки в ходе раскатки
This commit is contained in:
@@ -2,16 +2,17 @@ import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
from app.db import Base
|
||||
from app.postgres import create_postgres_engine
|
||||
from app.settings import get_settings
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name)
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
database_url = get_settings().database_url
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
@@ -28,11 +29,7 @@ def do_run_migrations(connection) -> None:
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
connectable = create_postgres_engine(database_url, poolclass=pool.NullPool)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
@@ -7,6 +7,7 @@ Create Date: 2026-07-10
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from app.db import Base
|
||||
|
||||
@@ -75,12 +76,18 @@ def upgrade() -> None:
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_dialog_one_active_per_user
|
||||
ON han_app.dialogs(user_id)
|
||||
WHERE record_status='A'
|
||||
AND status IN ('open','waiting_for_company','waiting_for_client');
|
||||
|
||||
AND status IN ('open','waiting_for_company','waiting_for_client')
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_profiles_active_bitrix_contact
|
||||
ON han_app.client_profiles(bitrix_contact_id)
|
||||
WHERE bitrix_contact_id IS NOT NULL AND record_status='A';
|
||||
|
||||
WHERE bitrix_contact_id IS NOT NULL AND record_status='A'
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
@@ -116,36 +123,48 @@ def upgrade() -> None:
|
||||
ON CONFLICT (dedup_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities"
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_identity_contact_sync
|
||||
AFTER INSERT OR UPDATE OF phone_number, record_status
|
||||
ON han_app.user_identities
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync()
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles"
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_profile_contact_sync
|
||||
AFTER INSERT OR UPDATE OF full_name, citizenship, russian_phone,
|
||||
foreign_phone, email, record_status
|
||||
ON han_app.client_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync()
|
||||
"""
|
||||
)
|
||||
for key, (value, value_type, public) in SEED.items():
|
||||
bind.exec_driver_sql(
|
||||
"""
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key, setting_value, value_type, is_public, record_status, updated_at)
|
||||
VALUES (%s, %s, %s, %s, 'A', now())
|
||||
VALUES (:key, :value, :value_type, :public, 'A', now())
|
||||
ON CONFLICT (setting_key) DO UPDATE SET
|
||||
setting_value = EXCLUDED.setting_value,
|
||||
value_type = EXCLUDED.value_type,
|
||||
is_public = EXCLUDED.is_public,
|
||||
record_status = 'A',
|
||||
updated_at = now()
|
||||
""",
|
||||
(key, value, value_type, public),
|
||||
"""
|
||||
),
|
||||
{"key": key, "value": value, "value_type": value_type, "public": public},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,10 +22,11 @@ from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from app.postgres import create_postgres_engine
|
||||
|
||||
SCHEMA = "han_app"
|
||||
|
||||
|
||||
@@ -347,7 +348,7 @@ class EntityExternalMapping(Base):
|
||||
|
||||
class Database:
|
||||
def __init__(self, url: str) -> None:
|
||||
self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True)
|
||||
self.engine: AsyncEngine = create_postgres_engine(url, pool_pre_ping=True)
|
||||
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
||||
|
||||
async def session(self) -> AsyncIterator[AsyncSession]:
|
||||
|
||||
@@ -101,7 +101,7 @@ async def refresh_settings_cache(app: FastAPI) -> None:
|
||||
async with app.state.db.sessions() as db:
|
||||
app.state.snapshot = await load_settings(db)
|
||||
except Exception:
|
||||
log.warning("settings.refresh_failed", event="settings.refresh_failed")
|
||||
log.warning("settings.refresh_failed")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
@@ -126,12 +126,12 @@ async def lifespan(app: FastAPI):
|
||||
async with app.state.db.sessions() as db:
|
||||
app.state.snapshot = await load_settings(db)
|
||||
except Exception:
|
||||
structlog.get_logger().warning("settings_warmup_failed", event="settings.warmup_failed")
|
||||
structlog.get_logger().warning("settings.warmup_failed")
|
||||
settings_task = asyncio.create_task(refresh_settings_cache(app))
|
||||
try:
|
||||
await app.state.jwks.refresh()
|
||||
except Exception:
|
||||
structlog.get_logger().warning("jwks_warmup_failed", event="jwks.warmup_failed")
|
||||
structlog.get_logger().warning("jwks.warmup_failed")
|
||||
yield
|
||||
settings_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
@@ -211,7 +211,6 @@ async def request_context(request: Request, call_next: Any) -> Response:
|
||||
response.headers["Cache-Control"] = response.headers.get("Cache-Control", "no-store")
|
||||
log.info(
|
||||
"request.complete",
|
||||
event="request.complete",
|
||||
status_code=response.status_code,
|
||||
duration_ms=round((time.monotonic() - request.state.started_at) * 1000, 2),
|
||||
)
|
||||
@@ -262,7 +261,7 @@ async def http_error(request: Request, exc: StarletteHTTPException):
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_error(request: Request, exc: Exception):
|
||||
log.exception("request.failed", event="request.failed", error_code="internal_error")
|
||||
log.exception("request.failed", error_code="internal_error")
|
||||
return error_response(request, "internal_error", "Internal server error", 500)
|
||||
|
||||
|
||||
@@ -622,7 +621,7 @@ async def dialogs_create(
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
log.warning("idempotency.cache_write_failed", event="idempotency.cache_write_failed")
|
||||
log.warning("idempotency.cache_write_failed")
|
||||
return JSONResponse(json.loads(json.dumps(body, default=str)), status_code=status)
|
||||
|
||||
|
||||
@@ -792,7 +791,7 @@ async def message_create(
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
log.warning("idempotency.cache_write_failed", event="idempotency.cache_write_failed")
|
||||
log.warning("idempotency.cache_write_failed")
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
|
||||
def asyncpg_dsn(url: str) -> str:
|
||||
if url.startswith("postgresql+asyncpg://"):
|
||||
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
return url
|
||||
|
||||
|
||||
def create_postgres_engine(url: str, **engine_options: Any) -> AsyncEngine:
|
||||
dsn = asyncpg_dsn(url)
|
||||
|
||||
async def connect():
|
||||
return await asyncpg.connect(dsn=dsn)
|
||||
|
||||
return create_async_engine(
|
||||
"postgresql+asyncpg://",
|
||||
async_creator=connect,
|
||||
**engine_options,
|
||||
)
|
||||
@@ -5,6 +5,7 @@ from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.auth import canonical_phone
|
||||
from app.integrations import CircuitBreaker, RateLimiter
|
||||
from app.postgres import asyncpg_dsn
|
||||
from app.schemas import (
|
||||
FileMessageRequest,
|
||||
MessageRequest,
|
||||
@@ -15,6 +16,15 @@ from app.schemas import (
|
||||
)
|
||||
|
||||
|
||||
def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None:
|
||||
url = (
|
||||
"postgresql+asyncpg://user:password@db:5433/han_chat"
|
||||
"?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem"
|
||||
)
|
||||
|
||||
assert asyncpg_dsn(url) == url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
|
||||
|
||||
def test_phone_claim_priority_and_e164_validation() -> None:
|
||||
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
|
||||
assert canonical_phone(claims) == "+74999591007"
|
||||
|
||||
Reference in New Issue
Block a user