Allow FAQ creation API to pass display order and status

This commit is contained in:
Egor
2025-10-07 06:28:26 +03:00
parent ddfa61c56f
commit 64a4ece0fe
5 changed files with 658 additions and 1 deletions

View File

@@ -156,14 +156,18 @@ class FaqService:
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
lang = cls._normalize_language(language)
is_active_value = True if is_active is None else bool(is_active)
page = await create_faq_page(
db,
language=lang,
title=title,
content=content,
is_active=True,
display_order=display_order,
is_active=is_active_value,
)
setting = await get_faq_setting(db, lang)

View File

@@ -16,6 +16,7 @@ from .routes import (
miniapp,
promo_groups,
promo_offers,
pages,
remnawave,
stats,
subscriptions,
@@ -78,6 +79,10 @@ OPENAPI_TAGS = [
"name": "miniapp",
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
},
{
"name": "pages",
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
},
]
@@ -115,6 +120,7 @@ def create_web_api_app() -> FastAPI:
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
app.include_router(pages.router, prefix="/pages", tags=["pages"])
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
app.include_router(backups.router, prefix="/backups", tags=["backups"])

View File

@@ -3,6 +3,7 @@ from . import (
health,
miniapp,
promo_offers,
pages,
promo_groups,
remnawave,
stats,
@@ -18,6 +19,7 @@ __all__ = [
"health",
"miniapp",
"promo_offers",
"pages",
"promo_groups",
"remnawave",
"stats",

492
app/webapi/routes/pages.py Normal file
View File

@@ -0,0 +1,492 @@
from __future__ import annotations
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.faq import get_faq_page_by_id
from app.database.crud.rules import (
clear_all_rules,
create_or_update_rules,
get_all_rules_versions,
get_rules_by_language,
restore_rules_version,
)
from app.database.models import ServiceRule
from app.services.faq_service import FaqService
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from ..dependencies import get_db_session, require_api_token
from ..schemas.pages import (
FaqPageCreateRequest,
FaqPageListResponse,
FaqPageResponse,
FaqPageUpdateRequest,
FaqReorderRequest,
FaqStatusResponse,
FaqStatusUpdateRequest,
RichTextPageResponse,
RichTextPageUpdateRequest,
ServiceRulesHistoryResponse,
ServiceRulesResponse,
ServiceRulesUpdateRequest,
)
router = APIRouter()
def _serialize_rich_page(
*,
requested_language: str,
content: str,
language: str,
is_enabled: Optional[bool],
created_at,
updated_at,
splitter,
) -> RichTextPageResponse:
pages = splitter(content or "")
return RichTextPageResponse(
requested_language=requested_language,
language=language,
is_enabled=is_enabled,
content=content or "",
content_pages=pages,
created_at=created_at,
updated_at=updated_at,
)
def _serialize_faq_page(page) -> FaqPageResponse:
return FaqPageResponse(
id=page.id,
language=page.language,
title=page.title,
content=page.content,
content_pages=FaqService.split_content_into_pages(page.content),
display_order=page.display_order,
is_active=page.is_active,
created_at=page.created_at,
updated_at=page.updated_at,
)
def _serialize_rules(rule: ServiceRule) -> ServiceRulesResponse:
return ServiceRulesResponse(
id=rule.id,
title=rule.title,
content=rule.content,
language=rule.language,
is_active=rule.is_active,
created_at=rule.created_at,
updated_at=rule.updated_at,
)
@router.get("/public-offer", response_model=RichTextPageResponse)
async def get_public_offer(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True, description="Использовать запасной язык, если контента нет"),
include_disabled: bool = Query(
True,
description="Возвращать контент даже если страница выключена",
),
) -> RichTextPageResponse:
requested_lang = PublicOfferService.normalize_language(language)
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=fallback)
if not offer:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer not found")
if not include_disabled and not offer.is_enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer disabled")
return _serialize_rich_page(
requested_language=requested_lang,
language=offer.language,
is_enabled=offer.is_enabled,
content=offer.content or "",
created_at=offer.created_at,
updated_at=offer.updated_at,
splitter=PublicOfferService.split_content_into_pages,
)
@router.put("/public-offer", response_model=RichTextPageResponse)
async def update_public_offer(
payload: RichTextPageUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RichTextPageResponse:
lang = PublicOfferService.normalize_language(payload.language)
offer = await PublicOfferService.save_offer(db, lang, payload.content)
if payload.is_enabled is not None:
offer = await PublicOfferService.set_enabled(db, lang, payload.is_enabled)
refreshed = await PublicOfferService.get_offer(db, lang, fallback=False)
offer = refreshed or offer
return _serialize_rich_page(
requested_language=lang,
language=offer.language,
is_enabled=offer.is_enabled,
content=offer.content or "",
created_at=offer.created_at,
updated_at=offer.updated_at,
splitter=PublicOfferService.split_content_into_pages,
)
@router.get("/privacy-policy", response_model=RichTextPageResponse)
async def get_privacy_policy(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
include_disabled: bool = Query(True),
) -> RichTextPageResponse:
requested_lang = PrivacyPolicyService.normalize_language(language)
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=fallback)
if not policy:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy not found")
if not include_disabled and not policy.is_enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy disabled")
return _serialize_rich_page(
requested_language=requested_lang,
language=policy.language,
is_enabled=policy.is_enabled,
content=policy.content or "",
created_at=policy.created_at,
updated_at=policy.updated_at,
splitter=PrivacyPolicyService.split_content_into_pages,
)
@router.put("/privacy-policy", response_model=RichTextPageResponse)
async def update_privacy_policy(
payload: RichTextPageUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RichTextPageResponse:
lang = PrivacyPolicyService.normalize_language(payload.language)
policy = await PrivacyPolicyService.save_policy(db, lang, payload.content)
if payload.is_enabled is not None:
policy = await PrivacyPolicyService.set_enabled(db, lang, payload.is_enabled)
refreshed = await PrivacyPolicyService.get_policy(db, lang, fallback=False)
policy = refreshed or policy
return _serialize_rich_page(
requested_language=lang,
language=policy.language,
is_enabled=policy.is_enabled,
content=policy.content or "",
created_at=policy.created_at,
updated_at=policy.updated_at,
splitter=PrivacyPolicyService.split_content_into_pages,
)
@router.get("/faq", response_model=FaqPageListResponse)
async def list_faq_pages(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
include_inactive: bool = Query(True),
fallback: bool = Query(True),
) -> FaqPageListResponse:
requested_lang = FaqService.normalize_language(language)
pages = await FaqService.get_pages(
db,
requested_lang,
include_inactive=include_inactive,
fallback=fallback,
)
resolved_language = requested_lang
if pages:
resolved_language = pages[0].language
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
is_enabled = bool(setting.is_enabled) if setting else False
if setting:
resolved_language = setting.language
serialized = [_serialize_faq_page(page) for page in pages]
return FaqPageListResponse(
requested_language=requested_lang,
language=resolved_language,
is_enabled=is_enabled,
total=len(serialized),
items=serialized,
)
@router.post("/faq", response_model=FaqPageResponse, status_code=status.HTTP_201_CREATED)
async def create_faq_page(
payload: FaqPageCreateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqPageResponse:
lang = FaqService.normalize_language(payload.language)
is_active = True if payload.is_active is None else payload.is_active
page = await FaqService.create_page(
db,
language=lang,
title=payload.title,
content=payload.content,
display_order=payload.display_order,
is_active=is_active,
)
return _serialize_faq_page(page)
@router.get("/faq/{page_id}", response_model=FaqPageResponse)
async def get_faq_page(
page_id: int,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
include_inactive: bool = Query(True),
) -> FaqPageResponse:
requested_lang = FaqService.normalize_language(language)
page = await FaqService.get_page(
db,
page_id,
requested_lang,
include_inactive=include_inactive,
fallback=True,
)
if not page:
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
return _serialize_faq_page(page)
@router.put("/faq/{page_id}", response_model=FaqPageResponse)
async def update_faq_page(
page_id: int,
payload: FaqPageUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqPageResponse:
page = await get_faq_page_by_id(db, page_id)
if not page:
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
updated = await FaqService.update_page(
db,
page,
title=payload.title,
content=payload.content,
display_order=payload.display_order,
is_active=payload.is_active,
)
return _serialize_faq_page(updated)
@router.delete("/faq/{page_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_faq_page(
page_id: int,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
page = await get_faq_page_by_id(db, page_id)
if not page:
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
await FaqService.delete_page(db, page_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/faq/reorder", response_model=FaqPageListResponse)
async def reorder_faq_pages(
payload: FaqReorderRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqPageListResponse:
lang = FaqService.normalize_language(payload.language)
ordered_payload = sorted(payload.items, key=lambda item: item.display_order)
existing_pages = await FaqService.get_pages(
db,
lang,
include_inactive=True,
fallback=False,
)
pages_by_id = {page.id: page for page in existing_pages}
pages: List[Any] = []
for item in ordered_payload:
page = pages_by_id.get(item.id)
if not page:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
f"FAQ page {item.id} not found for language {lang}",
)
pages.append(page)
ordered_ids = {item.id for item in ordered_payload}
remaining = [page for page in existing_pages if page.id not in ordered_ids]
pages.extend(sorted(remaining, key=lambda page: (page.display_order, page.id)))
await FaqService.reorder_pages(db, lang, pages)
updated_pages = await FaqService.get_pages(
db,
lang,
include_inactive=True,
fallback=False,
)
setting = await FaqService.get_setting(db, lang, fallback=False)
serialized = [_serialize_faq_page(page) for page in updated_pages]
return FaqPageListResponse(
requested_language=lang,
language=lang,
is_enabled=bool(setting.is_enabled) if setting else False,
total=len(serialized),
items=serialized,
)
@router.get("/faq/status", response_model=FaqStatusResponse)
async def get_faq_status(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
) -> FaqStatusResponse:
requested_lang = FaqService.normalize_language(language)
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
if not setting:
return FaqStatusResponse(
requested_language=requested_lang,
language=requested_lang,
is_enabled=False,
)
return FaqStatusResponse(
requested_language=requested_lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.put("/faq/status", response_model=FaqStatusResponse)
async def update_faq_status(
payload: FaqStatusUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqStatusResponse:
lang = FaqService.normalize_language(payload.language)
setting = await FaqService.set_enabled(db, lang, payload.is_enabled)
return FaqStatusResponse(
requested_language=lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.get("/service-rules", response_model=ServiceRulesResponse)
async def get_service_rules(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
) -> ServiceRulesResponse:
requested_lang = language.split("-")[0].lower()
rules = await get_rules_by_language(db, requested_lang)
if not rules and fallback:
default_lang = (settings.DEFAULT_LANGUAGE or "ru").split("-")[0].lower()
if default_lang != requested_lang:
rules = await get_rules_by_language(db, default_lang)
if not rules:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Service rules not found")
return _serialize_rules(rules)
@router.put("/service-rules", response_model=ServiceRulesResponse)
async def update_service_rules(
payload: ServiceRulesUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ServiceRulesResponse:
lang = payload.language.split("-")[0].lower()
title = payload.title or "Правила сервиса"
rules = await create_or_update_rules(
db,
content=payload.content,
language=lang,
title=title,
)
return _serialize_rules(rules)
@router.delete("/service-rules", status_code=status.HTTP_204_NO_CONTENT)
async def clear_service_rules(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
) -> Response:
lang = language.split("-")[0].lower()
await clear_all_rules(db, lang)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/service-rules/history", response_model=ServiceRulesHistoryResponse)
async def get_service_rules_history(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
limit: int = Query(10, ge=1, le=100),
) -> ServiceRulesHistoryResponse:
lang = language.split("-")[0].lower()
history = await get_all_rules_versions(db, lang, limit=limit)
items = [_serialize_rules(item) for item in history]
return ServiceRulesHistoryResponse(
language=lang,
total=len(items),
items=items,
)
@router.post(
"/service-rules/history/{rule_id}/restore",
response_model=ServiceRulesResponse,
status_code=status.HTTP_201_CREATED,
)
async def restore_service_rules_version(
rule_id: int,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
) -> ServiceRulesResponse:
lang = language.split("-")[0].lower()
restored = await restore_rules_version(db, rule_id, language=lang)
if not restored:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Rules version not found")
return _serialize_rules(restored)

153
app/webapi/schemas/pages.py Normal file
View File

@@ -0,0 +1,153 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class RichTextPageResponse(BaseModel):
"""Generic representation for rich text informational pages."""
requested_language: str = Field(..., description="Язык, запрошенный клиентом")
language: str = Field(..., description="Фактический язык найденной записи")
is_enabled: Optional[bool] = Field(
default=None,
description="Текущий статус публикации страницы (если применимо)",
)
content: str = Field(..., description="Полное содержимое страницы")
content_pages: List[str] = Field(
default_factory=list,
description="Содержимое, разбитое на страницы фиксированной длины",
)
created_at: Optional[datetime] = Field(
default=None,
description="Дата создания записи",
)
updated_at: Optional[datetime] = Field(
default=None,
description="Дата последнего обновления записи",
)
class RichTextPageUpdateRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
description="Язык, для которого выполняется обновление",
)
content: str = Field(..., description="Новое содержимое страницы")
is_enabled: Optional[bool] = Field(
default=None,
description="Если указано — обновить статус публикации",
)
class FaqPageResponse(BaseModel):
id: int
language: str
title: str
content: str
content_pages: List[str] = Field(default_factory=list)
display_order: int
is_active: bool
created_at: datetime
updated_at: datetime
class FaqPageListResponse(BaseModel):
requested_language: str
language: str
is_enabled: bool
total: int
items: List[FaqPageResponse]
class FaqPageCreateRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
description="Язык создаваемой страницы",
)
title: str = Field(..., min_length=1, max_length=255)
content: str = Field(...)
display_order: Optional[int] = Field(
default=None,
ge=0,
description="Порядок отображения (если не указан — будет рассчитан автоматически)",
)
is_active: Optional[bool] = Field(
default=True,
description="Начальный статус активности страницы",
)
class FaqPageUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
content: Optional[str] = None
display_order: Optional[int] = Field(default=None, ge=0)
is_active: Optional[bool] = None
class FaqReorderItem(BaseModel):
id: int = Field(..., ge=1)
display_order: int = Field(..., ge=0)
class FaqReorderRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
description="Язык, для которого применяется сортировка",
)
items: List[FaqReorderItem]
class FaqStatusResponse(BaseModel):
requested_language: str
language: str
is_enabled: bool
class FaqStatusUpdateRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
)
is_enabled: bool
class ServiceRulesResponse(BaseModel):
id: int
title: str
content: str
language: str
is_active: bool
created_at: datetime
updated_at: datetime
class ServiceRulesUpdateRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
description="Язык, для которого обновляются правила",
)
title: Optional[str] = Field(
default="Правила сервиса",
min_length=1,
max_length=255,
)
content: str = Field(...)
class ServiceRulesHistoryResponse(BaseModel):
language: str
total: int
items: List[ServiceRulesResponse]