diff --git a/app/cabinet/routes/admin_info_pages.py b/app/cabinet/routes/admin_info_pages.py index f5dd5bc3..3749e186 100644 --- a/app/cabinet/routes/admin_info_pages.py +++ b/app/cabinet/routes/admin_info_pages.py @@ -6,6 +6,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.info_pages import ( + clear_replaces_tab, create_info_page, delete_info_page, get_all_info_pages, @@ -74,6 +75,9 @@ async def create_page( ) -> InfoPageResponse: """Create a new info page.""" try: + if request.replaces_tab: + await clear_replaces_tab(db, request.replaces_tab) + page = await create_info_page( db, slug=request.slug, @@ -83,6 +87,7 @@ async def create_page( is_active=request.is_active, sort_order=request.sort_order, icon=request.icon, + replaces_tab=request.replaces_tab, ) except IntegrityError: raise HTTPException( @@ -116,6 +121,11 @@ async def update_page( try: update_data = request.model_dump(exclude_unset=True) + + replaces_tab = update_data.get('replaces_tab') + if replaces_tab is not None: + await clear_replaces_tab(db, replaces_tab, exclude_page_id=page_id) + page = await update_info_page(db, page_id, **update_data) except IntegrityError: raise HTTPException( diff --git a/app/cabinet/routes/info_pages.py b/app/cabinet/routes/info_pages.py index 1fb622be..676969b0 100644 --- a/app/cabinet/routes/info_pages.py +++ b/app/cabinet/routes/info_pages.py @@ -4,7 +4,7 @@ import structlog from fastapi import APIRouter, Depends, HTTPException, Path, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug +from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug, get_tab_replacements from ..dependencies import get_cabinet_db from ..schemas.info_pages import InfoPageListItem, InfoPageResponse @@ -32,6 +32,25 @@ async def list_active_info_pages( ) +@router.get('/tab-replacements') +async def get_info_page_tab_replacements( + db: AsyncSession = Depends(get_cabinet_db), +) -> dict[str, str | None]: + """Get tab replacement mapping (public, no auth required). + + Returns a dict mapping each replaceable tab to the info page slug that replaces it, + or null if no replacement is set: ``{faq: slug_or_null, ...}``. + """ + try: + return await get_tab_replacements(db) + except Exception: + logger.exception('Failed to get tab replacements') + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to load tab replacements', + ) + + @router.get('/{slug}', response_model=InfoPageResponse) async def get_info_page_by_slug_public( slug: str = Path(..., max_length=200, pattern=r'^[a-z0-9\-]+$'), diff --git a/app/cabinet/schemas/info_pages.py b/app/cabinet/schemas/info_pages.py index 0e5b9262..84e4e19a 100644 --- a/app/cabinet/schemas/info_pages.py +++ b/app/cabinet/schemas/info_pages.py @@ -16,6 +16,7 @@ class InfoPageResponse(BaseModel): is_active: bool sort_order: int icon: str | None = None + replaces_tab: str | None = None created_at: datetime updated_at: datetime | None = None @@ -32,6 +33,7 @@ class InfoPageListItem(BaseModel): is_active: bool sort_order: int icon: str | None = None + replaces_tab: str | None = None updated_at: datetime | None = None model_config = ConfigDict(from_attributes=True) @@ -47,6 +49,7 @@ class InfoPageCreateRequest(BaseModel): is_active: bool = True sort_order: int = 0 icon: str | None = Field(None, max_length=50) + replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$') class InfoPageUpdateRequest(BaseModel): @@ -59,6 +62,7 @@ class InfoPageUpdateRequest(BaseModel): is_active: bool | None = None sort_order: int | None = None icon: str | None = Field(None, max_length=50) + replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$') class ReorderItem(BaseModel): diff --git a/app/database/crud/info_pages.py b/app/database/crud/info_pages.py index f2cd8b1a..ad03b515 100644 --- a/app/database/crud/info_pages.py +++ b/app/database/crud/info_pages.py @@ -23,6 +23,7 @@ _ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset( 'is_active', 'sort_order', 'icon', + 'replaces_tab', } ) @@ -30,6 +31,7 @@ _ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset( _NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset( { 'icon', + 'replaces_tab', } ) @@ -44,6 +46,7 @@ async def create_info_page( is_active: bool = True, sort_order: int = 0, icon: str | None = None, + replaces_tab: str | None = None, ) -> InfoPage: """Create a new info page. @@ -58,6 +61,7 @@ async def create_info_page( is_active=is_active, sort_order=sort_order, icon=icon, + replaces_tab=replaces_tab, ) db.add(page) @@ -150,6 +154,48 @@ async def delete_info_page(db: AsyncSession, page_id: int) -> None: logger.info('Deleted info page', page_id=page_id) +async def get_tab_replacements(db: AsyncSession) -> dict[str, str | None]: + """Return a mapping of tab name to info page slug for active pages with replaces_tab set. + + Returns dict like ``{'faq': 'my-custom-faq', 'rules': None, 'privacy': None, 'offer': None}``. + """ + result_map: dict[str, str | None] = { + 'faq': None, + 'rules': None, + 'privacy': None, + 'offer': None, + } + + stmt = select(InfoPage).where( + InfoPage.is_active.is_(True), + InfoPage.replaces_tab.isnot(None), + ) + result = await db.execute(stmt) + for page in result.scalars().all(): + if page.replaces_tab in result_map: + result_map[page.replaces_tab] = page.slug + + return result_map + + +async def clear_replaces_tab(db: AsyncSession, tab: str, *, exclude_page_id: int | None = None) -> None: + """Clear replaces_tab for all pages that currently replace the given tab. + + Optionally exclude a specific page (the one being saved). + """ + stmt = ( + update(InfoPage) + .where( + InfoPage.replaces_tab == tab, + ) + .values(replaces_tab=None, updated_at=datetime.now(UTC)) + ) + if exclude_page_id is not None: + stmt = stmt.where(InfoPage.id != exclude_page_id) + + await db.execute(stmt) + + async def reorder_info_pages(db: AsyncSession, items: list[dict]) -> None: """Bulk update sort_order for info pages. diff --git a/app/database/models.py b/app/database/models.py index a75382c8..8e710ceb 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -3713,5 +3713,6 @@ class InfoPage(Base): is_active = Column(Boolean, nullable=False, default=True, server_default='true') sort_order = Column(Integer, nullable=False, default=0, server_default='0') icon = Column(String(50), nullable=True) + replaces_tab = Column(String(20), nullable=True) # 'faq', 'rules', 'privacy', 'offer', or null created_at = Column(AwareDateTime(), server_default=func.now()) updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now()) diff --git a/migrations/alembic/versions/0067_add_replaces_tab_to_info_pages.py b/migrations/alembic/versions/0067_add_replaces_tab_to_info_pages.py new file mode 100644 index 00000000..4985ee13 --- /dev/null +++ b/migrations/alembic/versions/0067_add_replaces_tab_to_info_pages.py @@ -0,0 +1,28 @@ +"""add replaces_tab to info_pages + +Revision ID: 0067 +Revises: 0066 +Create Date: 2026-04-21 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0067' +down_revision: Union[str, None] = '0066' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'info_pages', + sa.Column('replaces_tab', sa.String(20), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('info_pages', 'replaces_tab')