diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index e13fb7c7..a6d3515d 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -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) diff --git a/app/cabinet/routes/admin_info_pages.py b/app/cabinet/routes/admin_info_pages.py new file mode 100644 index 00000000..bf5e5579 --- /dev/null +++ b/app/cabinet/routes/admin_info_pages.py @@ -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', + ) diff --git a/app/cabinet/routes/info_pages.py b/app/cabinet/routes/info_pages.py new file mode 100644 index 00000000..bf6f202e --- /dev/null +++ b/app/cabinet/routes/info_pages.py @@ -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) diff --git a/app/cabinet/schemas/info_pages.py b/app/cabinet/schemas/info_pages.py new file mode 100644 index 00000000..c93dcb00 --- /dev/null +++ b/app/cabinet/schemas/info_pages.py @@ -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) diff --git a/app/database/crud/info_pages.py b/app/database/crud/info_pages.py new file mode 100644 index 00000000..0ab16972 --- /dev/null +++ b/app/database/crud/info_pages.py @@ -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)) diff --git a/app/database/models.py b/app/database/models.py index 87648a74..2f3184a9 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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()) diff --git a/migrations/alembic/versions/0065_create_info_pages.py b/migrations/alembic/versions/0065_create_info_pages.py new file mode 100644 index 00000000..0f5840e3 --- /dev/null +++ b/migrations/alembic/versions/0065_create_info_pages.py @@ -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()