feat: info page tab replacement — replaces_tab field + API

- Add replaces_tab column to InfoPage ('faq','rules','privacy','offer')
- Migration 0067: add nullable replaces_tab column
- CRUD: clear_replaces_tab ensures one page per tab, get_tab_replacements
  returns {tab: slug} mapping for active pages
- Admin routes: auto-clear old assignment on create/update
- Public route: GET /info-pages/tab-replacements (no auth)
- Schemas: replaces_tab with regex validation in all request/response models
This commit is contained in:
Fringg
2026-04-24 14:21:32 +03:00
parent d394565fe9
commit bdb8cab1c9
6 changed files with 109 additions and 1 deletions
+10
View File
@@ -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(
+20 -1
View File
@@ -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\-]+$'),
+4
View File
@@ -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):
+46
View File
@@ -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.
+1
View File
@@ -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())
@@ -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')