feat: information pages — CRUD model, admin API, public API

- InfoPage model: slug, title (JSONB locale dict), content (JSONB),
  is_active, sort_order, icon, created_at/updated_at
- CRUD: create, get by id/slug, list, update, delete, reorder
- Admin routes: /admin/info-pages with full CRUD, toggle-active, reorder
  (permissions: settings:read/settings:edit)
- Public routes: /info-pages list active, /info-pages/{slug} get by slug
- Migration 0065: create info_pages table with unique slug index
- Custom pages support: admins can create any info page with any slug
This commit is contained in:
Fringg
2026-04-24 08:09:54 +03:00
parent 59c54c9b39
commit e4b4a54797
7 changed files with 550 additions and 0 deletions
+4
View File
@@ -12,6 +12,7 @@ from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_info_pages import router as admin_info_pages_router
from .admin_landings import router as admin_landings_router
from .admin_menu_layout import router as admin_menu_layout_router
from .admin_news import router as admin_news_router
@@ -45,6 +46,7 @@ from .branding import router as branding_router
from .contests import router as contests_router
from .gift import router as gift_router
from .info import router as info_router
from .info_pages import router as info_pages_router
from .landing import router as landing_router
from .media import router as media_router
from .news import router as news_router
@@ -94,6 +96,7 @@ router.include_router(branding_router)
router.include_router(landing_router)
router.include_router(media_router)
router.include_router(news_router)
router.include_router(info_pages_router)
# Wheel routes
router.include_router(wheel_router)
@@ -142,6 +145,7 @@ router.include_router(admin_news_categories_router)
router.include_router(admin_news_tags_router)
router.include_router(admin_news_media_router)
router.include_router(admin_news_router)
router.include_router(admin_info_pages_router)
# WebSocket route
router.include_router(websocket_router)
+207
View File
@@ -0,0 +1,207 @@
"""Admin routes for managing info pages in cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.info_pages import (
create_info_page,
delete_info_page,
get_all_info_pages,
get_info_page_by_id,
reorder_info_pages,
update_info_page,
)
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.info_pages import (
InfoPageCreateRequest,
InfoPageListItem,
InfoPageResponse,
InfoPageUpdateRequest,
ReorderRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages'])
@router.get('', response_model=list[InfoPageListItem])
async def list_all_info_pages(
admin: User = Depends(require_permission('settings:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all info pages (admin view, includes inactive)."""
try:
pages = await get_all_info_pages(db, include_inactive=True)
return [InfoPageListItem.model_validate(p) for p in pages]
except HTTPException:
raise
except Exception:
logger.exception('Failed to list info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load info pages',
)
@router.get('/{page_id}', response_model=InfoPageResponse)
async def get_info_page_detail(
page_id: int,
admin: User = Depends(require_permission('settings:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by ID (admin view)."""
page = await get_info_page_by_id(db, page_id)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
return InfoPageResponse.model_validate(page)
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
async def create_page(
request: InfoPageCreateRequest,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Create a new info page."""
try:
page = await create_info_page(
db,
slug=request.slug,
title=request.title,
content=request.content,
is_active=request.is_active,
sort_order=request.sort_order,
icon=request.icon,
)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An info page with this slug already exists',
)
except Exception:
logger.exception('Failed to create info page')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create info page',
)
return InfoPageResponse.model_validate(page)
@router.put('/{page_id}', response_model=InfoPageResponse)
async def update_page(
page_id: int,
request: InfoPageUpdateRequest,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Update an existing info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
update_data = request.model_dump(exclude_unset=True)
page = await update_info_page(db, page_id, **update_data)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An info page with this slug already exists',
)
except Exception:
logger.exception('Failed to update info page', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update info page',
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found after update',
)
return InfoPageResponse.model_validate(page)
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_page(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete an info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
await delete_info_page(db, page_id)
except Exception:
logger.exception('Failed to delete info page', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete info page',
)
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
async def toggle_active(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Toggle the active status of an info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
page = await update_info_page(db, page_id, is_active=not existing.is_active)
except Exception:
logger.exception('Failed to toggle info page active status', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle active status',
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found after toggle',
)
return InfoPageResponse.model_validate(page)
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
async def reorder_pages(
request: ReorderRequest,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Bulk update sort_order for info pages."""
try:
await reorder_info_pages(db, request.items)
except Exception:
logger.exception('Failed to reorder info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reorder info pages',
)
+48
View File
@@ -0,0 +1,48 @@
"""Public info page routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug
from ..dependencies import get_cabinet_db
from ..schemas.info_pages import InfoPageListItem, InfoPageResponse
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/info-pages', tags=['Cabinet Info Pages'])
@router.get('', response_model=list[InfoPageListItem])
async def list_active_info_pages(
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all active info pages (public, no auth required)."""
try:
pages = await get_all_info_pages(db, include_inactive=False)
return [InfoPageListItem.model_validate(p) for p in pages]
except Exception:
logger.exception('Failed to list active info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load info pages',
)
@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\-]+$'),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by slug (public, no auth required)."""
page = await get_info_page_by_slug(db, slug)
if not page or not page.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
return InfoPageResponse.model_validate(page)
+63
View File
@@ -0,0 +1,63 @@
"""Schemas for info pages in cabinet."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class InfoPageResponse(BaseModel):
"""Full info page response."""
id: int
slug: str
title: dict[str, str]
content: dict[str, str]
is_active: bool
sort_order: int
icon: str | None = None
created_at: datetime
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class InfoPageListItem(BaseModel):
"""Compact info page for list views."""
id: int
slug: str
title: dict[str, str]
is_active: bool
sort_order: int
icon: str | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class InfoPageCreateRequest(BaseModel):
"""Request to create an info page."""
slug: str = Field(min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
title: dict[str, str] = Field(default_factory=dict)
content: dict[str, str] = Field(default_factory=dict)
is_active: bool = True
sort_order: int = 0
icon: str | None = Field(None, max_length=50)
class InfoPageUpdateRequest(BaseModel):
"""Request to update an info page."""
slug: str | None = Field(None, min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
title: dict[str, str] | None = None
content: dict[str, str] | None = None
is_active: bool | None = None
sort_order: int | None = None
icon: str | None = Field(None, max_length=50)
class ReorderRequest(BaseModel):
"""Request to bulk-reorder info pages."""
items: list[dict] = Field(..., min_length=1)
+162
View File
@@ -0,0 +1,162 @@
"""CRUD operations for info pages."""
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import InfoPage
logger = structlog.get_logger(__name__)
# Fields that can be set via update_info_page
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'slug',
'title',
'content',
'is_active',
'sort_order',
'icon',
}
)
# Fields that can be explicitly set to None
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'icon',
}
)
async def create_info_page(
db: AsyncSession,
*,
slug: str,
title: dict[str, str],
content: dict[str, str],
is_active: bool = True,
sort_order: int = 0,
icon: str | None = None,
) -> InfoPage:
"""Create a new info page.
Raises:
IntegrityError: if slug is not unique (caller must handle).
"""
page = InfoPage(
slug=slug,
title=title,
content=content,
is_active=is_active,
sort_order=sort_order,
icon=icon,
)
db.add(page)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(page)
logger.info('Created info page', page_id=page.id, slug=page.slug)
return page
async def get_info_page_by_id(db: AsyncSession, page_id: int) -> InfoPage | None:
"""Get an info page by ID."""
result = await db.execute(select(InfoPage).where(InfoPage.id == page_id))
return result.scalar_one_or_none()
async def get_info_page_by_slug(db: AsyncSession, slug: str) -> InfoPage | None:
"""Get an info page by slug."""
result = await db.execute(select(InfoPage).where(InfoPage.slug == slug))
return result.scalar_one_or_none()
async def get_all_info_pages(
db: AsyncSession,
*,
include_inactive: bool = False,
) -> list[InfoPage]:
"""Get all info pages, ordered by sort_order ascending."""
stmt = select(InfoPage)
if not include_inactive:
stmt = stmt.where(InfoPage.is_active.is_(True))
stmt = stmt.order_by(InfoPage.sort_order.asc(), InfoPage.id.asc())
result = await db.execute(stmt)
return list(result.scalars().all())
async def update_info_page(
db: AsyncSession,
page_id: int,
**kwargs: Any,
) -> InfoPage | None:
"""Update an info page. Only whitelisted fields are applied.
Raises:
IntegrityError: if slug conflicts with another page (caller must handle).
"""
update_data: dict[str, Any] = {}
for key, value in kwargs.items():
if key not in _ALLOWED_UPDATE_FIELDS:
continue
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
continue
update_data[key] = value
if not update_data:
return await get_info_page_by_id(db, page_id)
update_data['updated_at'] = datetime.now(UTC)
await db.execute(update(InfoPage).where(InfoPage.id == page_id).values(**update_data))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
page = await get_info_page_by_id(db, page_id)
if page:
logger.info(
'Updated info page',
page_id=page_id,
updated_fields=list(update_data.keys()),
)
return page
async def delete_info_page(db: AsyncSession, page_id: int) -> None:
"""Delete an info page."""
await db.execute(delete(InfoPage).where(InfoPage.id == page_id))
await db.commit()
logger.info('Deleted info page', page_id=page_id)
async def reorder_info_pages(db: AsyncSession, items: list[dict]) -> None:
"""Bulk update sort_order for info pages.
Each dict in *items* must have ``id`` and ``sort_order`` keys.
"""
for item in items:
page_id = item.get('id')
sort_order = item.get('sort_order')
if page_id is None or sort_order is None:
continue
await db.execute(
update(InfoPage).where(InfoPage.id == page_id).values(sort_order=sort_order, updated_at=datetime.now(UTC))
)
await db.commit()
logger.info('Reordered info pages', count=len(items))
+17
View File
@@ -3699,3 +3699,20 @@ class YandexClientIdMap(Base):
subid = Column(String(255), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
class InfoPage(Base):
"""Static informational page with multilingual title/content (JSONB)."""
__tablename__ = 'info_pages'
__table_args__ = (Index('ix_info_pages_slug', 'slug', unique=True),)
id = Column(Integer, primary_key=True, index=True)
slug = Column(String(200), unique=True, nullable=False, index=True)
title = Column(JSONB, nullable=False, server_default='{}')
content = Column(JSONB, nullable=False, server_default='{}')
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)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,49 @@
"""create info_pages table
Revision ID: 0065
Revises: 0064
Create Date: 2026-04-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0065'
down_revision: Union[str, None] = '0064'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
if not _table_exists('info_pages'):
op.create_table(
'info_pages',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('slug', sa.String(200), unique=True, nullable=False, index=True),
sa.Column('title', sa.dialects.postgresql.JSONB(), nullable=False, server_default='{}'),
sa.Column('content', sa.dialects.postgresql.JSONB(), nullable=False, server_default='{}'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('icon', sa.String(50), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index('ix_info_pages_slug', 'info_pages', ['slug'], unique=True)
def downgrade() -> None:
op.drop_index('ix_info_pages_slug', table_name='info_pages')
op.drop_table('info_pages')
def _table_exists(table_name: str) -> bool:
"""Check if a table already exists (idempotent migration guard)."""
bind = op.get_bind()
result = bind.execute(
sa.text('SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = :name)'),
{'name': table_name},
)
return result.scalar()