From 1c3dbd57c579e6805a696d5d233312cb5f69b055 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 25 Nov 2025 01:06:05 +0300 Subject: [PATCH 1/7] Add API endpoints for welcome texts and menu messages --- app/database/crud/user_message.py | 18 ++-- app/database/crud/welcome_text.py | 108 +++++++++++++++++++++++- app/webapi/app.py | 18 +++- app/webapi/routes/__init__.py | 4 + app/webapi/routes/user_messages.py | 124 ++++++++++++++++++++++++++++ app/webapi/routes/welcome_texts.py | 122 +++++++++++++++++++++++++++ app/webapi/schemas/user_messages.py | 50 +++++++++++ app/webapi/schemas/welcome_texts.py | 50 +++++++++++ 8 files changed, 487 insertions(+), 7 deletions(-) create mode 100644 app/webapi/routes/user_messages.py create mode 100644 app/webapi/routes/welcome_texts.py create mode 100644 app/webapi/schemas/user_messages.py create mode 100644 app/webapi/schemas/welcome_texts.py diff --git a/app/database/crud/user_message.py b/app/database/crud/user_message.py index 488a27d0..3f26f026 100644 --- a/app/database/crud/user_message.py +++ b/app/database/crud/user_message.py @@ -61,19 +61,27 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]: async def get_all_user_messages( db: AsyncSession, offset: int = 0, - limit: int = 50 + limit: int = 50, + include_inactive: bool = True, ) -> List[UserMessage]: + query = select(UserMessage).order_by(UserMessage.created_at.desc()) + if not include_inactive: + query = query.where(UserMessage.is_active == True) + result = await db.execute( - select(UserMessage) - .order_by(UserMessage.created_at.desc()) + query .offset(offset) .limit(limit) ) return result.scalars().all() -async def get_user_messages_count(db: AsyncSession) -> int: - result = await db.execute(select(func.count(UserMessage.id))) +async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int: + query = select(func.count(UserMessage.id)) + if not include_inactive: + query = query.where(UserMessage.is_active == True) + + result = await db.execute(query) return result.scalar() diff --git a/app/database/crud/welcome_text.py b/app/database/crud/welcome_text.py index 2e1646eb..145f6ac8 100644 --- a/app/database/crud/welcome_text.py +++ b/app/database/crud/welcome_text.py @@ -1,7 +1,7 @@ import logging from datetime import datetime from typing import Optional -from sqlalchemy import select, update +from sqlalchemy import select, update, func from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import WelcomeText @@ -45,6 +45,37 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict: 'id': None } + +async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]: + result = await db.execute( + select(WelcomeText).where(WelcomeText.id == welcome_text_id) + ) + return result.scalar_one_or_none() + + +async def list_welcome_texts( + db: AsyncSession, + *, + include_inactive: bool = True, + limit: int = 50, + offset: int = 0, +): + query = select(WelcomeText).order_by(WelcomeText.updated_at.desc()) + if not include_inactive: + query = query.where(WelcomeText.is_active == True) + + result = await db.execute(query.limit(limit).offset(offset)) + return result.scalars().all() + + +async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int: + query = select(func.count(WelcomeText.id)) + if not include_inactive: + query = query.where(WelcomeText.is_active == True) + + result = await db.execute(query) + return result.scalar() + async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool: try: result = await db.execute( @@ -113,6 +144,81 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) - await db.rollback() return False + +async def create_welcome_text( + db: AsyncSession, + *, + text_content: str, + created_by: Optional[int] = None, + is_enabled: bool = True, + is_active: bool = True, +) -> WelcomeText: + if is_active: + await db.execute(update(WelcomeText).values(is_active=False)) + + welcome_text = WelcomeText( + text_content=text_content, + is_active=is_active, + is_enabled=is_enabled, + created_by=created_by, + ) + + db.add(welcome_text) + await db.commit() + await db.refresh(welcome_text) + + logger.info( + "✅ Создан приветственный текст ID %s (активный=%s, включен=%s)", + welcome_text.id, + welcome_text.is_active, + welcome_text.is_enabled, + ) + return welcome_text + + +async def update_welcome_text( + db: AsyncSession, + welcome_text: WelcomeText, + *, + text_content: Optional[str] = None, + is_enabled: Optional[bool] = None, + is_active: Optional[bool] = None, +) -> WelcomeText: + if is_active: + await db.execute( + update(WelcomeText) + .where(WelcomeText.id != welcome_text.id) + .values(is_active=False) + ) + + if text_content is not None: + welcome_text.text_content = text_content + + if is_enabled is not None: + welcome_text.is_enabled = is_enabled + + if is_active is not None: + welcome_text.is_active = is_active + + welcome_text.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(welcome_text) + + logger.info( + "📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)", + welcome_text.id, + welcome_text.is_active, + welcome_text.is_enabled, + ) + return welcome_text + + +async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None: + await db.delete(welcome_text) + await db.commit() + logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id) + async def get_current_welcome_text_or_default() -> str: return ( f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! " diff --git a/app/webapi/app.py b/app/webapi/app.py index 6e87aa69..ba58d493 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -20,6 +20,8 @@ from .routes import ( promocodes, promo_groups, promo_offers, + user_messages, + welcome_texts, pages, remnawave, servers, @@ -49,7 +51,11 @@ OPENAPI_TAGS = [ }, { "name": "main-menu", - "description": "Управление кнопками главного меню Telegram-бота.", + "description": "Управление кнопками и сообщениями главного меню Telegram-бота.", + }, + { + "name": "welcome-texts", + "description": "Создание, редактирование и управление приветственными текстами.", }, { "name": "users", @@ -169,6 +175,16 @@ def create_web_api_app() -> FastAPI: prefix="/main-menu/buttons", tags=["main-menu"], ) + app.include_router( + user_messages.router, + prefix="/main-menu/messages", + tags=["main-menu"], + ) + app.include_router( + welcome_texts.router, + prefix="/welcome-texts", + tags=["welcome-texts"], + ) 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"]) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 4484462a..cbe4030a 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -7,6 +7,8 @@ from . import ( partners, polls, promo_offers, + user_messages, + welcome_texts, pages, promo_groups, servers, @@ -30,6 +32,8 @@ __all__ = [ "partners", "polls", "promo_offers", + "user_messages", + "welcome_texts", "pages", "promo_groups", "servers", diff --git a/app/webapi/routes/user_messages.py b/app/webapi/routes/user_messages.py new file mode 100644 index 00000000..d6fba008 --- /dev/null +++ b/app/webapi/routes/user_messages.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.user_message import ( + create_user_message, + delete_user_message, + get_all_user_messages, + get_user_message_by_id, + get_user_messages_count, + toggle_user_message_status, + update_user_message, +) + +from ..dependencies import get_db_session, require_api_token +from ..schemas.user_messages import ( + UserMessageCreateRequest, + UserMessageListResponse, + UserMessageResponse, + UserMessageUpdateRequest, +) + +router = APIRouter() + + +def _serialize(message) -> UserMessageResponse: + return UserMessageResponse( + id=message.id, + message_text=message.message_text, + is_active=message.is_active, + sort_order=message.sort_order, + created_by=message.created_by, + created_at=message.created_at, + updated_at=message.updated_at, + ) + + +@router.get("", response_model=UserMessageListResponse) +async def list_user_messages( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + include_inactive: bool = Query(True, description="Включать неактивные сообщения"), +) -> UserMessageListResponse: + total = await get_user_messages_count(db, include_inactive=include_inactive) + messages = await get_all_user_messages( + db, + offset=offset, + limit=limit, + include_inactive=include_inactive, + ) + + return UserMessageListResponse( + items=[_serialize(message) for message in messages], + total=total, + limit=limit, + offset=offset, + ) + + +@router.post("", response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED) +async def create_user_message_endpoint( + payload: UserMessageCreateRequest, + token: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserMessageResponse: + created_by = getattr(token, "id", None) + message = await create_user_message( + db, + message_text=payload.message_text, + created_by=created_by, + is_active=payload.is_active, + sort_order=payload.sort_order, + ) + + return _serialize(message) + + +@router.patch("/{message_id}", response_model=UserMessageResponse) +async def update_user_message_endpoint( + message_id: int, + payload: UserMessageUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserMessageResponse: + update_payload = payload.dict(exclude_unset=True) + message = await update_user_message(db, message_id, **update_payload) + + if not message: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + + return _serialize(message) + + +@router.post("/{message_id}/toggle", response_model=UserMessageResponse) +async def toggle_user_message_endpoint( + message_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserMessageResponse: + message = await toggle_user_message_status(db, message_id) + + if not message: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + + return _serialize(message) + + +@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user_message_endpoint( + message_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + message = await get_user_message_by_id(db, message_id) + if not message: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + + await delete_user_message(db, message_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/routes/welcome_texts.py b/app/webapi/routes/welcome_texts.py new file mode 100644 index 00000000..906cc0d8 --- /dev/null +++ b/app/webapi/routes/welcome_texts.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.welcome_text import ( + count_welcome_texts, + create_welcome_text, + delete_welcome_text, + get_welcome_text_by_id, + list_welcome_texts, + update_welcome_text, +) + +from ..dependencies import get_db_session, require_api_token +from ..schemas.welcome_texts import ( + WelcomeTextCreateRequest, + WelcomeTextListResponse, + WelcomeTextResponse, + WelcomeTextUpdateRequest, +) + +router = APIRouter() + + +def _serialize(text) -> WelcomeTextResponse: + return WelcomeTextResponse( + id=text.id, + text=text.text_content, + is_active=text.is_active, + is_enabled=text.is_enabled, + created_by=text.created_by, + created_at=text.created_at, + updated_at=text.updated_at, + ) + + +@router.get("", response_model=WelcomeTextListResponse) +async def list_welcome_texts_endpoint( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + include_inactive: bool = Query(True, description="Включать неактивные тексты"), +) -> WelcomeTextListResponse: + total = await count_welcome_texts(db, include_inactive=include_inactive) + records = await list_welcome_texts( + db, + limit=limit, + offset=offset, + include_inactive=include_inactive, + ) + + return WelcomeTextListResponse( + items=[_serialize(item) for item in records], + total=total, + limit=limit, + offset=offset, + ) + + +@router.post("", response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED) +async def create_welcome_text_endpoint( + payload: WelcomeTextCreateRequest, + token: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WelcomeTextResponse: + created_by = getattr(token, "id", None) + record = await create_welcome_text( + db, + text_content=payload.text, + created_by=created_by, + is_enabled=payload.is_enabled, + is_active=payload.is_active, + ) + + return _serialize(record) + + +@router.get("/{welcome_text_id}", response_model=WelcomeTextResponse) +async def get_welcome_text_endpoint( + welcome_text_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WelcomeTextResponse: + record = await get_welcome_text_by_id(db, welcome_text_id) + if not record: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + + return _serialize(record) + + +@router.patch("/{welcome_text_id}", response_model=WelcomeTextResponse) +async def update_welcome_text_endpoint( + welcome_text_id: int, + payload: WelcomeTextUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WelcomeTextResponse: + record = await get_welcome_text_by_id(db, welcome_text_id) + if not record: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + + update_payload = payload.dict(exclude_unset=True) + updated = await update_welcome_text(db, record, **update_payload) + return _serialize(updated) + + +@router.delete("/{welcome_text_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_welcome_text_endpoint( + welcome_text_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + record = await get_welcome_text_by_id(db, welcome_text_id) + if not record: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + + await delete_welcome_text(db, record) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/schemas/user_messages.py b/app/webapi/schemas/user_messages.py new file mode 100644 index 00000000..97f41c69 --- /dev/null +++ b/app/webapi/schemas/user_messages.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, validator + + +def _normalize_text(value: str) -> str: + cleaned = (value or "").strip() + if not cleaned: + raise ValueError("Message text cannot be empty") + return cleaned + + +class UserMessageResponse(BaseModel): + id: int + message_text: str + is_active: bool + sort_order: int + created_by: Optional[int] + created_at: datetime + updated_at: datetime + + +class UserMessageCreateRequest(BaseModel): + message_text: str = Field(..., min_length=1, max_length=4000) + is_active: bool = True + sort_order: int = Field(0, ge=0) + + _normalize_message_text = validator("message_text", allow_reuse=True)(_normalize_text) + + +class UserMessageUpdateRequest(BaseModel): + message_text: Optional[str] = Field(None, min_length=1, max_length=4000) + is_active: Optional[bool] = None + sort_order: Optional[int] = Field(None, ge=0) + + @validator("message_text") + def validate_message_text(cls, value): # noqa: D401,B902 + if value is None: + return value + return _normalize_text(value) + + +class UserMessageListResponse(BaseModel): + items: list[UserMessageResponse] + total: int + limit: int + offset: int diff --git a/app/webapi/schemas/welcome_texts.py b/app/webapi/schemas/welcome_texts.py new file mode 100644 index 00000000..e68c9410 --- /dev/null +++ b/app/webapi/schemas/welcome_texts.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, validator + + +def _normalize_text(value: str) -> str: + cleaned = (value or "").strip() + if not cleaned: + raise ValueError("Text cannot be empty") + return cleaned + + +class WelcomeTextResponse(BaseModel): + id: int + text: str + is_active: bool + is_enabled: bool + created_by: Optional[int] + created_at: datetime + updated_at: datetime + + +class WelcomeTextCreateRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=4000) + is_enabled: bool = True + is_active: bool = True + + _normalize_text = validator("text", allow_reuse=True)(_normalize_text) + + +class WelcomeTextUpdateRequest(BaseModel): + text: Optional[str] = Field(None, min_length=1, max_length=4000) + is_enabled: Optional[bool] = None + is_active: Optional[bool] = None + + @validator("text") + def validate_text(cls, value): # noqa: D401,B902 + if value is None: + return value + return _normalize_text(value) + + +class WelcomeTextListResponse(BaseModel): + items: list[WelcomeTextResponse] + total: int + limit: int + offset: int From 83093236fc25a28d3db8f676b13c9ee1b17c1b14 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 25 Nov 2025 01:18:03 +0300 Subject: [PATCH 2/7] Handle missing creator on user message creation --- app/database/crud/user_message.py | 30 +++++-- app/database/crud/welcome_text.py | 108 +++++++++++++++++++++++- app/webapi/app.py | 18 +++- app/webapi/routes/__init__.py | 4 + app/webapi/routes/user_messages.py | 124 ++++++++++++++++++++++++++++ app/webapi/routes/welcome_texts.py | 122 +++++++++++++++++++++++++++ app/webapi/schemas/user_messages.py | 50 +++++++++++ app/webapi/schemas/welcome_texts.py | 50 +++++++++++ 8 files changed, 496 insertions(+), 10 deletions(-) create mode 100644 app/webapi/routes/user_messages.py create mode 100644 app/webapi/routes/welcome_texts.py create mode 100644 app/webapi/schemas/user_messages.py create mode 100644 app/webapi/schemas/welcome_texts.py diff --git a/app/database/crud/user_message.py b/app/database/crud/user_message.py index 488a27d0..03960eff 100644 --- a/app/database/crud/user_message.py +++ b/app/database/crud/user_message.py @@ -5,7 +5,7 @@ from typing import Optional, List from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import UserMessage +from app.database.models import User, UserMessage logger = logging.getLogger(__name__) @@ -13,15 +13,21 @@ logger = logging.getLogger(__name__) async def create_user_message( db: AsyncSession, message_text: str, - created_by: int, + created_by: Optional[int] = None, is_active: bool = True, sort_order: int = 0 ) -> UserMessage: + resolved_creator = created_by + + if created_by is not None: + result = await db.execute(select(User.id).where(User.id == created_by)) + resolved_creator = result.scalar_one_or_none() + message = UserMessage( message_text=message_text, is_active=is_active, sort_order=sort_order, - created_by=created_by + created_by=resolved_creator, ) db.add(message) @@ -61,19 +67,27 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]: async def get_all_user_messages( db: AsyncSession, offset: int = 0, - limit: int = 50 + limit: int = 50, + include_inactive: bool = True, ) -> List[UserMessage]: + query = select(UserMessage).order_by(UserMessage.created_at.desc()) + if not include_inactive: + query = query.where(UserMessage.is_active == True) + result = await db.execute( - select(UserMessage) - .order_by(UserMessage.created_at.desc()) + query .offset(offset) .limit(limit) ) return result.scalars().all() -async def get_user_messages_count(db: AsyncSession) -> int: - result = await db.execute(select(func.count(UserMessage.id))) +async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int: + query = select(func.count(UserMessage.id)) + if not include_inactive: + query = query.where(UserMessage.is_active == True) + + result = await db.execute(query) return result.scalar() diff --git a/app/database/crud/welcome_text.py b/app/database/crud/welcome_text.py index 2e1646eb..145f6ac8 100644 --- a/app/database/crud/welcome_text.py +++ b/app/database/crud/welcome_text.py @@ -1,7 +1,7 @@ import logging from datetime import datetime from typing import Optional -from sqlalchemy import select, update +from sqlalchemy import select, update, func from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import WelcomeText @@ -45,6 +45,37 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict: 'id': None } + +async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]: + result = await db.execute( + select(WelcomeText).where(WelcomeText.id == welcome_text_id) + ) + return result.scalar_one_or_none() + + +async def list_welcome_texts( + db: AsyncSession, + *, + include_inactive: bool = True, + limit: int = 50, + offset: int = 0, +): + query = select(WelcomeText).order_by(WelcomeText.updated_at.desc()) + if not include_inactive: + query = query.where(WelcomeText.is_active == True) + + result = await db.execute(query.limit(limit).offset(offset)) + return result.scalars().all() + + +async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int: + query = select(func.count(WelcomeText.id)) + if not include_inactive: + query = query.where(WelcomeText.is_active == True) + + result = await db.execute(query) + return result.scalar() + async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool: try: result = await db.execute( @@ -113,6 +144,81 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) - await db.rollback() return False + +async def create_welcome_text( + db: AsyncSession, + *, + text_content: str, + created_by: Optional[int] = None, + is_enabled: bool = True, + is_active: bool = True, +) -> WelcomeText: + if is_active: + await db.execute(update(WelcomeText).values(is_active=False)) + + welcome_text = WelcomeText( + text_content=text_content, + is_active=is_active, + is_enabled=is_enabled, + created_by=created_by, + ) + + db.add(welcome_text) + await db.commit() + await db.refresh(welcome_text) + + logger.info( + "✅ Создан приветственный текст ID %s (активный=%s, включен=%s)", + welcome_text.id, + welcome_text.is_active, + welcome_text.is_enabled, + ) + return welcome_text + + +async def update_welcome_text( + db: AsyncSession, + welcome_text: WelcomeText, + *, + text_content: Optional[str] = None, + is_enabled: Optional[bool] = None, + is_active: Optional[bool] = None, +) -> WelcomeText: + if is_active: + await db.execute( + update(WelcomeText) + .where(WelcomeText.id != welcome_text.id) + .values(is_active=False) + ) + + if text_content is not None: + welcome_text.text_content = text_content + + if is_enabled is not None: + welcome_text.is_enabled = is_enabled + + if is_active is not None: + welcome_text.is_active = is_active + + welcome_text.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(welcome_text) + + logger.info( + "📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)", + welcome_text.id, + welcome_text.is_active, + welcome_text.is_enabled, + ) + return welcome_text + + +async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None: + await db.delete(welcome_text) + await db.commit() + logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id) + async def get_current_welcome_text_or_default() -> str: return ( f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! " diff --git a/app/webapi/app.py b/app/webapi/app.py index 6e87aa69..ba58d493 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -20,6 +20,8 @@ from .routes import ( promocodes, promo_groups, promo_offers, + user_messages, + welcome_texts, pages, remnawave, servers, @@ -49,7 +51,11 @@ OPENAPI_TAGS = [ }, { "name": "main-menu", - "description": "Управление кнопками главного меню Telegram-бота.", + "description": "Управление кнопками и сообщениями главного меню Telegram-бота.", + }, + { + "name": "welcome-texts", + "description": "Создание, редактирование и управление приветственными текстами.", }, { "name": "users", @@ -169,6 +175,16 @@ def create_web_api_app() -> FastAPI: prefix="/main-menu/buttons", tags=["main-menu"], ) + app.include_router( + user_messages.router, + prefix="/main-menu/messages", + tags=["main-menu"], + ) + app.include_router( + welcome_texts.router, + prefix="/welcome-texts", + tags=["welcome-texts"], + ) 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"]) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 4484462a..cbe4030a 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -7,6 +7,8 @@ from . import ( partners, polls, promo_offers, + user_messages, + welcome_texts, pages, promo_groups, servers, @@ -30,6 +32,8 @@ __all__ = [ "partners", "polls", "promo_offers", + "user_messages", + "welcome_texts", "pages", "promo_groups", "servers", diff --git a/app/webapi/routes/user_messages.py b/app/webapi/routes/user_messages.py new file mode 100644 index 00000000..d6fba008 --- /dev/null +++ b/app/webapi/routes/user_messages.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.user_message import ( + create_user_message, + delete_user_message, + get_all_user_messages, + get_user_message_by_id, + get_user_messages_count, + toggle_user_message_status, + update_user_message, +) + +from ..dependencies import get_db_session, require_api_token +from ..schemas.user_messages import ( + UserMessageCreateRequest, + UserMessageListResponse, + UserMessageResponse, + UserMessageUpdateRequest, +) + +router = APIRouter() + + +def _serialize(message) -> UserMessageResponse: + return UserMessageResponse( + id=message.id, + message_text=message.message_text, + is_active=message.is_active, + sort_order=message.sort_order, + created_by=message.created_by, + created_at=message.created_at, + updated_at=message.updated_at, + ) + + +@router.get("", response_model=UserMessageListResponse) +async def list_user_messages( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + include_inactive: bool = Query(True, description="Включать неактивные сообщения"), +) -> UserMessageListResponse: + total = await get_user_messages_count(db, include_inactive=include_inactive) + messages = await get_all_user_messages( + db, + offset=offset, + limit=limit, + include_inactive=include_inactive, + ) + + return UserMessageListResponse( + items=[_serialize(message) for message in messages], + total=total, + limit=limit, + offset=offset, + ) + + +@router.post("", response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED) +async def create_user_message_endpoint( + payload: UserMessageCreateRequest, + token: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserMessageResponse: + created_by = getattr(token, "id", None) + message = await create_user_message( + db, + message_text=payload.message_text, + created_by=created_by, + is_active=payload.is_active, + sort_order=payload.sort_order, + ) + + return _serialize(message) + + +@router.patch("/{message_id}", response_model=UserMessageResponse) +async def update_user_message_endpoint( + message_id: int, + payload: UserMessageUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserMessageResponse: + update_payload = payload.dict(exclude_unset=True) + message = await update_user_message(db, message_id, **update_payload) + + if not message: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + + return _serialize(message) + + +@router.post("/{message_id}/toggle", response_model=UserMessageResponse) +async def toggle_user_message_endpoint( + message_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserMessageResponse: + message = await toggle_user_message_status(db, message_id) + + if not message: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + + return _serialize(message) + + +@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user_message_endpoint( + message_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + message = await get_user_message_by_id(db, message_id) + if not message: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + + await delete_user_message(db, message_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/routes/welcome_texts.py b/app/webapi/routes/welcome_texts.py new file mode 100644 index 00000000..906cc0d8 --- /dev/null +++ b/app/webapi/routes/welcome_texts.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.welcome_text import ( + count_welcome_texts, + create_welcome_text, + delete_welcome_text, + get_welcome_text_by_id, + list_welcome_texts, + update_welcome_text, +) + +from ..dependencies import get_db_session, require_api_token +from ..schemas.welcome_texts import ( + WelcomeTextCreateRequest, + WelcomeTextListResponse, + WelcomeTextResponse, + WelcomeTextUpdateRequest, +) + +router = APIRouter() + + +def _serialize(text) -> WelcomeTextResponse: + return WelcomeTextResponse( + id=text.id, + text=text.text_content, + is_active=text.is_active, + is_enabled=text.is_enabled, + created_by=text.created_by, + created_at=text.created_at, + updated_at=text.updated_at, + ) + + +@router.get("", response_model=WelcomeTextListResponse) +async def list_welcome_texts_endpoint( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + include_inactive: bool = Query(True, description="Включать неактивные тексты"), +) -> WelcomeTextListResponse: + total = await count_welcome_texts(db, include_inactive=include_inactive) + records = await list_welcome_texts( + db, + limit=limit, + offset=offset, + include_inactive=include_inactive, + ) + + return WelcomeTextListResponse( + items=[_serialize(item) for item in records], + total=total, + limit=limit, + offset=offset, + ) + + +@router.post("", response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED) +async def create_welcome_text_endpoint( + payload: WelcomeTextCreateRequest, + token: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WelcomeTextResponse: + created_by = getattr(token, "id", None) + record = await create_welcome_text( + db, + text_content=payload.text, + created_by=created_by, + is_enabled=payload.is_enabled, + is_active=payload.is_active, + ) + + return _serialize(record) + + +@router.get("/{welcome_text_id}", response_model=WelcomeTextResponse) +async def get_welcome_text_endpoint( + welcome_text_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WelcomeTextResponse: + record = await get_welcome_text_by_id(db, welcome_text_id) + if not record: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + + return _serialize(record) + + +@router.patch("/{welcome_text_id}", response_model=WelcomeTextResponse) +async def update_welcome_text_endpoint( + welcome_text_id: int, + payload: WelcomeTextUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WelcomeTextResponse: + record = await get_welcome_text_by_id(db, welcome_text_id) + if not record: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + + update_payload = payload.dict(exclude_unset=True) + updated = await update_welcome_text(db, record, **update_payload) + return _serialize(updated) + + +@router.delete("/{welcome_text_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_welcome_text_endpoint( + welcome_text_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + record = await get_welcome_text_by_id(db, welcome_text_id) + if not record: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + + await delete_welcome_text(db, record) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/schemas/user_messages.py b/app/webapi/schemas/user_messages.py new file mode 100644 index 00000000..97f41c69 --- /dev/null +++ b/app/webapi/schemas/user_messages.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, validator + + +def _normalize_text(value: str) -> str: + cleaned = (value or "").strip() + if not cleaned: + raise ValueError("Message text cannot be empty") + return cleaned + + +class UserMessageResponse(BaseModel): + id: int + message_text: str + is_active: bool + sort_order: int + created_by: Optional[int] + created_at: datetime + updated_at: datetime + + +class UserMessageCreateRequest(BaseModel): + message_text: str = Field(..., min_length=1, max_length=4000) + is_active: bool = True + sort_order: int = Field(0, ge=0) + + _normalize_message_text = validator("message_text", allow_reuse=True)(_normalize_text) + + +class UserMessageUpdateRequest(BaseModel): + message_text: Optional[str] = Field(None, min_length=1, max_length=4000) + is_active: Optional[bool] = None + sort_order: Optional[int] = Field(None, ge=0) + + @validator("message_text") + def validate_message_text(cls, value): # noqa: D401,B902 + if value is None: + return value + return _normalize_text(value) + + +class UserMessageListResponse(BaseModel): + items: list[UserMessageResponse] + total: int + limit: int + offset: int diff --git a/app/webapi/schemas/welcome_texts.py b/app/webapi/schemas/welcome_texts.py new file mode 100644 index 00000000..e68c9410 --- /dev/null +++ b/app/webapi/schemas/welcome_texts.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, validator + + +def _normalize_text(value: str) -> str: + cleaned = (value or "").strip() + if not cleaned: + raise ValueError("Text cannot be empty") + return cleaned + + +class WelcomeTextResponse(BaseModel): + id: int + text: str + is_active: bool + is_enabled: bool + created_by: Optional[int] + created_at: datetime + updated_at: datetime + + +class WelcomeTextCreateRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=4000) + is_enabled: bool = True + is_active: bool = True + + _normalize_text = validator("text", allow_reuse=True)(_normalize_text) + + +class WelcomeTextUpdateRequest(BaseModel): + text: Optional[str] = Field(None, min_length=1, max_length=4000) + is_enabled: Optional[bool] = None + is_active: Optional[bool] = None + + @validator("text") + def validate_text(cls, value): # noqa: D401,B902 + if value is None: + return value + return _normalize_text(value) + + +class WelcomeTextListResponse(BaseModel): + items: list[WelcomeTextResponse] + total: int + limit: int + offset: int From 2d4b46afbd8a1c5704ce773a8a2ccfa6bb72e06c Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 25 Nov 2025 01:18:45 +0300 Subject: [PATCH 3/7] Revert "Expose admin APIs for welcome texts and menu messages" --- app/database/crud/user_message.py | 18 ++-- app/database/crud/welcome_text.py | 108 +----------------------- app/webapi/app.py | 18 +--- app/webapi/routes/__init__.py | 4 - app/webapi/routes/user_messages.py | 124 ---------------------------- app/webapi/routes/welcome_texts.py | 122 --------------------------- app/webapi/schemas/user_messages.py | 50 ----------- app/webapi/schemas/welcome_texts.py | 50 ----------- 8 files changed, 7 insertions(+), 487 deletions(-) delete mode 100644 app/webapi/routes/user_messages.py delete mode 100644 app/webapi/routes/welcome_texts.py delete mode 100644 app/webapi/schemas/user_messages.py delete mode 100644 app/webapi/schemas/welcome_texts.py diff --git a/app/database/crud/user_message.py b/app/database/crud/user_message.py index 3f26f026..488a27d0 100644 --- a/app/database/crud/user_message.py +++ b/app/database/crud/user_message.py @@ -61,27 +61,19 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]: async def get_all_user_messages( db: AsyncSession, offset: int = 0, - limit: int = 50, - include_inactive: bool = True, + limit: int = 50 ) -> List[UserMessage]: - query = select(UserMessage).order_by(UserMessage.created_at.desc()) - if not include_inactive: - query = query.where(UserMessage.is_active == True) - result = await db.execute( - query + select(UserMessage) + .order_by(UserMessage.created_at.desc()) .offset(offset) .limit(limit) ) return result.scalars().all() -async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int: - query = select(func.count(UserMessage.id)) - if not include_inactive: - query = query.where(UserMessage.is_active == True) - - result = await db.execute(query) +async def get_user_messages_count(db: AsyncSession) -> int: + result = await db.execute(select(func.count(UserMessage.id))) return result.scalar() diff --git a/app/database/crud/welcome_text.py b/app/database/crud/welcome_text.py index 145f6ac8..2e1646eb 100644 --- a/app/database/crud/welcome_text.py +++ b/app/database/crud/welcome_text.py @@ -1,7 +1,7 @@ import logging from datetime import datetime from typing import Optional -from sqlalchemy import select, update, func +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import WelcomeText @@ -45,37 +45,6 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict: 'id': None } - -async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]: - result = await db.execute( - select(WelcomeText).where(WelcomeText.id == welcome_text_id) - ) - return result.scalar_one_or_none() - - -async def list_welcome_texts( - db: AsyncSession, - *, - include_inactive: bool = True, - limit: int = 50, - offset: int = 0, -): - query = select(WelcomeText).order_by(WelcomeText.updated_at.desc()) - if not include_inactive: - query = query.where(WelcomeText.is_active == True) - - result = await db.execute(query.limit(limit).offset(offset)) - return result.scalars().all() - - -async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int: - query = select(func.count(WelcomeText.id)) - if not include_inactive: - query = query.where(WelcomeText.is_active == True) - - result = await db.execute(query) - return result.scalar() - async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool: try: result = await db.execute( @@ -144,81 +113,6 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) - await db.rollback() return False - -async def create_welcome_text( - db: AsyncSession, - *, - text_content: str, - created_by: Optional[int] = None, - is_enabled: bool = True, - is_active: bool = True, -) -> WelcomeText: - if is_active: - await db.execute(update(WelcomeText).values(is_active=False)) - - welcome_text = WelcomeText( - text_content=text_content, - is_active=is_active, - is_enabled=is_enabled, - created_by=created_by, - ) - - db.add(welcome_text) - await db.commit() - await db.refresh(welcome_text) - - logger.info( - "✅ Создан приветственный текст ID %s (активный=%s, включен=%s)", - welcome_text.id, - welcome_text.is_active, - welcome_text.is_enabled, - ) - return welcome_text - - -async def update_welcome_text( - db: AsyncSession, - welcome_text: WelcomeText, - *, - text_content: Optional[str] = None, - is_enabled: Optional[bool] = None, - is_active: Optional[bool] = None, -) -> WelcomeText: - if is_active: - await db.execute( - update(WelcomeText) - .where(WelcomeText.id != welcome_text.id) - .values(is_active=False) - ) - - if text_content is not None: - welcome_text.text_content = text_content - - if is_enabled is not None: - welcome_text.is_enabled = is_enabled - - if is_active is not None: - welcome_text.is_active = is_active - - welcome_text.updated_at = datetime.utcnow() - - await db.commit() - await db.refresh(welcome_text) - - logger.info( - "📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)", - welcome_text.id, - welcome_text.is_active, - welcome_text.is_enabled, - ) - return welcome_text - - -async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None: - await db.delete(welcome_text) - await db.commit() - logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id) - async def get_current_welcome_text_or_default() -> str: return ( f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! " diff --git a/app/webapi/app.py b/app/webapi/app.py index ba58d493..6e87aa69 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -20,8 +20,6 @@ from .routes import ( promocodes, promo_groups, promo_offers, - user_messages, - welcome_texts, pages, remnawave, servers, @@ -51,11 +49,7 @@ OPENAPI_TAGS = [ }, { "name": "main-menu", - "description": "Управление кнопками и сообщениями главного меню Telegram-бота.", - }, - { - "name": "welcome-texts", - "description": "Создание, редактирование и управление приветственными текстами.", + "description": "Управление кнопками главного меню Telegram-бота.", }, { "name": "users", @@ -175,16 +169,6 @@ def create_web_api_app() -> FastAPI: prefix="/main-menu/buttons", tags=["main-menu"], ) - app.include_router( - user_messages.router, - prefix="/main-menu/messages", - tags=["main-menu"], - ) - app.include_router( - welcome_texts.router, - prefix="/welcome-texts", - tags=["welcome-texts"], - ) 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"]) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index cbe4030a..4484462a 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -7,8 +7,6 @@ from . import ( partners, polls, promo_offers, - user_messages, - welcome_texts, pages, promo_groups, servers, @@ -32,8 +30,6 @@ __all__ = [ "partners", "polls", "promo_offers", - "user_messages", - "welcome_texts", "pages", "promo_groups", "servers", diff --git a/app/webapi/routes/user_messages.py b/app/webapi/routes/user_messages.py deleted file mode 100644 index d6fba008..00000000 --- a/app/webapi/routes/user_messages.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.crud.user_message import ( - create_user_message, - delete_user_message, - get_all_user_messages, - get_user_message_by_id, - get_user_messages_count, - toggle_user_message_status, - update_user_message, -) - -from ..dependencies import get_db_session, require_api_token -from ..schemas.user_messages import ( - UserMessageCreateRequest, - UserMessageListResponse, - UserMessageResponse, - UserMessageUpdateRequest, -) - -router = APIRouter() - - -def _serialize(message) -> UserMessageResponse: - return UserMessageResponse( - id=message.id, - message_text=message.message_text, - is_active=message.is_active, - sort_order=message.sort_order, - created_by=message.created_by, - created_at=message.created_at, - updated_at=message.updated_at, - ) - - -@router.get("", response_model=UserMessageListResponse) -async def list_user_messages( - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - include_inactive: bool = Query(True, description="Включать неактивные сообщения"), -) -> UserMessageListResponse: - total = await get_user_messages_count(db, include_inactive=include_inactive) - messages = await get_all_user_messages( - db, - offset=offset, - limit=limit, - include_inactive=include_inactive, - ) - - return UserMessageListResponse( - items=[_serialize(message) for message in messages], - total=total, - limit=limit, - offset=offset, - ) - - -@router.post("", response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED) -async def create_user_message_endpoint( - payload: UserMessageCreateRequest, - token: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> UserMessageResponse: - created_by = getattr(token, "id", None) - message = await create_user_message( - db, - message_text=payload.message_text, - created_by=created_by, - is_active=payload.is_active, - sort_order=payload.sort_order, - ) - - return _serialize(message) - - -@router.patch("/{message_id}", response_model=UserMessageResponse) -async def update_user_message_endpoint( - message_id: int, - payload: UserMessageUpdateRequest, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> UserMessageResponse: - update_payload = payload.dict(exclude_unset=True) - message = await update_user_message(db, message_id, **update_payload) - - if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") - - return _serialize(message) - - -@router.post("/{message_id}/toggle", response_model=UserMessageResponse) -async def toggle_user_message_endpoint( - message_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> UserMessageResponse: - message = await toggle_user_message_status(db, message_id) - - if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") - - return _serialize(message) - - -@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user_message_endpoint( - message_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> Response: - message = await get_user_message_by_id(db, message_id) - if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") - - await delete_user_message(db, message_id) - return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/routes/welcome_texts.py b/app/webapi/routes/welcome_texts.py deleted file mode 100644 index 906cc0d8..00000000 --- a/app/webapi/routes/welcome_texts.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.crud.welcome_text import ( - count_welcome_texts, - create_welcome_text, - delete_welcome_text, - get_welcome_text_by_id, - list_welcome_texts, - update_welcome_text, -) - -from ..dependencies import get_db_session, require_api_token -from ..schemas.welcome_texts import ( - WelcomeTextCreateRequest, - WelcomeTextListResponse, - WelcomeTextResponse, - WelcomeTextUpdateRequest, -) - -router = APIRouter() - - -def _serialize(text) -> WelcomeTextResponse: - return WelcomeTextResponse( - id=text.id, - text=text.text_content, - is_active=text.is_active, - is_enabled=text.is_enabled, - created_by=text.created_by, - created_at=text.created_at, - updated_at=text.updated_at, - ) - - -@router.get("", response_model=WelcomeTextListResponse) -async def list_welcome_texts_endpoint( - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - include_inactive: bool = Query(True, description="Включать неактивные тексты"), -) -> WelcomeTextListResponse: - total = await count_welcome_texts(db, include_inactive=include_inactive) - records = await list_welcome_texts( - db, - limit=limit, - offset=offset, - include_inactive=include_inactive, - ) - - return WelcomeTextListResponse( - items=[_serialize(item) for item in records], - total=total, - limit=limit, - offset=offset, - ) - - -@router.post("", response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED) -async def create_welcome_text_endpoint( - payload: WelcomeTextCreateRequest, - token: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> WelcomeTextResponse: - created_by = getattr(token, "id", None) - record = await create_welcome_text( - db, - text_content=payload.text, - created_by=created_by, - is_enabled=payload.is_enabled, - is_active=payload.is_active, - ) - - return _serialize(record) - - -@router.get("/{welcome_text_id}", response_model=WelcomeTextResponse) -async def get_welcome_text_endpoint( - welcome_text_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> WelcomeTextResponse: - record = await get_welcome_text_by_id(db, welcome_text_id) - if not record: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") - - return _serialize(record) - - -@router.patch("/{welcome_text_id}", response_model=WelcomeTextResponse) -async def update_welcome_text_endpoint( - welcome_text_id: int, - payload: WelcomeTextUpdateRequest, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> WelcomeTextResponse: - record = await get_welcome_text_by_id(db, welcome_text_id) - if not record: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") - - update_payload = payload.dict(exclude_unset=True) - updated = await update_welcome_text(db, record, **update_payload) - return _serialize(updated) - - -@router.delete("/{welcome_text_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_welcome_text_endpoint( - welcome_text_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> Response: - record = await get_welcome_text_by_id(db, welcome_text_id) - if not record: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") - - await delete_welcome_text(db, record) - return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/schemas/user_messages.py b/app/webapi/schemas/user_messages.py deleted file mode 100644 index 97f41c69..00000000 --- a/app/webapi/schemas/user_messages.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field, validator - - -def _normalize_text(value: str) -> str: - cleaned = (value or "").strip() - if not cleaned: - raise ValueError("Message text cannot be empty") - return cleaned - - -class UserMessageResponse(BaseModel): - id: int - message_text: str - is_active: bool - sort_order: int - created_by: Optional[int] - created_at: datetime - updated_at: datetime - - -class UserMessageCreateRequest(BaseModel): - message_text: str = Field(..., min_length=1, max_length=4000) - is_active: bool = True - sort_order: int = Field(0, ge=0) - - _normalize_message_text = validator("message_text", allow_reuse=True)(_normalize_text) - - -class UserMessageUpdateRequest(BaseModel): - message_text: Optional[str] = Field(None, min_length=1, max_length=4000) - is_active: Optional[bool] = None - sort_order: Optional[int] = Field(None, ge=0) - - @validator("message_text") - def validate_message_text(cls, value): # noqa: D401,B902 - if value is None: - return value - return _normalize_text(value) - - -class UserMessageListResponse(BaseModel): - items: list[UserMessageResponse] - total: int - limit: int - offset: int diff --git a/app/webapi/schemas/welcome_texts.py b/app/webapi/schemas/welcome_texts.py deleted file mode 100644 index e68c9410..00000000 --- a/app/webapi/schemas/welcome_texts.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field, validator - - -def _normalize_text(value: str) -> str: - cleaned = (value or "").strip() - if not cleaned: - raise ValueError("Text cannot be empty") - return cleaned - - -class WelcomeTextResponse(BaseModel): - id: int - text: str - is_active: bool - is_enabled: bool - created_by: Optional[int] - created_at: datetime - updated_at: datetime - - -class WelcomeTextCreateRequest(BaseModel): - text: str = Field(..., min_length=1, max_length=4000) - is_enabled: bool = True - is_active: bool = True - - _normalize_text = validator("text", allow_reuse=True)(_normalize_text) - - -class WelcomeTextUpdateRequest(BaseModel): - text: Optional[str] = Field(None, min_length=1, max_length=4000) - is_enabled: Optional[bool] = None - is_active: Optional[bool] = None - - @validator("text") - def validate_text(cls, value): # noqa: D401,B902 - if value is None: - return value - return _normalize_text(value) - - -class WelcomeTextListResponse(BaseModel): - items: list[WelcomeTextResponse] - total: int - limit: int - offset: int From 46dfb59f3d71c888b92c008718074097df44c276 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 25 Nov 2025 01:23:54 +0300 Subject: [PATCH 4/7] Revert "Handle missing creator on user message creation" --- app/database/crud/user_message.py | 30 ++----- app/database/crud/welcome_text.py | 108 +----------------------- app/webapi/app.py | 18 +--- app/webapi/routes/__init__.py | 4 - app/webapi/routes/user_messages.py | 124 ---------------------------- app/webapi/routes/welcome_texts.py | 122 --------------------------- app/webapi/schemas/user_messages.py | 50 ----------- app/webapi/schemas/welcome_texts.py | 50 ----------- 8 files changed, 10 insertions(+), 496 deletions(-) delete mode 100644 app/webapi/routes/user_messages.py delete mode 100644 app/webapi/routes/welcome_texts.py delete mode 100644 app/webapi/schemas/user_messages.py delete mode 100644 app/webapi/schemas/welcome_texts.py diff --git a/app/database/crud/user_message.py b/app/database/crud/user_message.py index 03960eff..488a27d0 100644 --- a/app/database/crud/user_message.py +++ b/app/database/crud/user_message.py @@ -5,7 +5,7 @@ from typing import Optional, List from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User, UserMessage +from app.database.models import UserMessage logger = logging.getLogger(__name__) @@ -13,21 +13,15 @@ logger = logging.getLogger(__name__) async def create_user_message( db: AsyncSession, message_text: str, - created_by: Optional[int] = None, + created_by: int, is_active: bool = True, sort_order: int = 0 ) -> UserMessage: - resolved_creator = created_by - - if created_by is not None: - result = await db.execute(select(User.id).where(User.id == created_by)) - resolved_creator = result.scalar_one_or_none() - message = UserMessage( message_text=message_text, is_active=is_active, sort_order=sort_order, - created_by=resolved_creator, + created_by=created_by ) db.add(message) @@ -67,27 +61,19 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]: async def get_all_user_messages( db: AsyncSession, offset: int = 0, - limit: int = 50, - include_inactive: bool = True, + limit: int = 50 ) -> List[UserMessage]: - query = select(UserMessage).order_by(UserMessage.created_at.desc()) - if not include_inactive: - query = query.where(UserMessage.is_active == True) - result = await db.execute( - query + select(UserMessage) + .order_by(UserMessage.created_at.desc()) .offset(offset) .limit(limit) ) return result.scalars().all() -async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int: - query = select(func.count(UserMessage.id)) - if not include_inactive: - query = query.where(UserMessage.is_active == True) - - result = await db.execute(query) +async def get_user_messages_count(db: AsyncSession) -> int: + result = await db.execute(select(func.count(UserMessage.id))) return result.scalar() diff --git a/app/database/crud/welcome_text.py b/app/database/crud/welcome_text.py index 145f6ac8..2e1646eb 100644 --- a/app/database/crud/welcome_text.py +++ b/app/database/crud/welcome_text.py @@ -1,7 +1,7 @@ import logging from datetime import datetime from typing import Optional -from sqlalchemy import select, update, func +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import WelcomeText @@ -45,37 +45,6 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict: 'id': None } - -async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]: - result = await db.execute( - select(WelcomeText).where(WelcomeText.id == welcome_text_id) - ) - return result.scalar_one_or_none() - - -async def list_welcome_texts( - db: AsyncSession, - *, - include_inactive: bool = True, - limit: int = 50, - offset: int = 0, -): - query = select(WelcomeText).order_by(WelcomeText.updated_at.desc()) - if not include_inactive: - query = query.where(WelcomeText.is_active == True) - - result = await db.execute(query.limit(limit).offset(offset)) - return result.scalars().all() - - -async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int: - query = select(func.count(WelcomeText.id)) - if not include_inactive: - query = query.where(WelcomeText.is_active == True) - - result = await db.execute(query) - return result.scalar() - async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool: try: result = await db.execute( @@ -144,81 +113,6 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) - await db.rollback() return False - -async def create_welcome_text( - db: AsyncSession, - *, - text_content: str, - created_by: Optional[int] = None, - is_enabled: bool = True, - is_active: bool = True, -) -> WelcomeText: - if is_active: - await db.execute(update(WelcomeText).values(is_active=False)) - - welcome_text = WelcomeText( - text_content=text_content, - is_active=is_active, - is_enabled=is_enabled, - created_by=created_by, - ) - - db.add(welcome_text) - await db.commit() - await db.refresh(welcome_text) - - logger.info( - "✅ Создан приветственный текст ID %s (активный=%s, включен=%s)", - welcome_text.id, - welcome_text.is_active, - welcome_text.is_enabled, - ) - return welcome_text - - -async def update_welcome_text( - db: AsyncSession, - welcome_text: WelcomeText, - *, - text_content: Optional[str] = None, - is_enabled: Optional[bool] = None, - is_active: Optional[bool] = None, -) -> WelcomeText: - if is_active: - await db.execute( - update(WelcomeText) - .where(WelcomeText.id != welcome_text.id) - .values(is_active=False) - ) - - if text_content is not None: - welcome_text.text_content = text_content - - if is_enabled is not None: - welcome_text.is_enabled = is_enabled - - if is_active is not None: - welcome_text.is_active = is_active - - welcome_text.updated_at = datetime.utcnow() - - await db.commit() - await db.refresh(welcome_text) - - logger.info( - "📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)", - welcome_text.id, - welcome_text.is_active, - welcome_text.is_enabled, - ) - return welcome_text - - -async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None: - await db.delete(welcome_text) - await db.commit() - logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id) - async def get_current_welcome_text_or_default() -> str: return ( f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! " diff --git a/app/webapi/app.py b/app/webapi/app.py index ba58d493..6e87aa69 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -20,8 +20,6 @@ from .routes import ( promocodes, promo_groups, promo_offers, - user_messages, - welcome_texts, pages, remnawave, servers, @@ -51,11 +49,7 @@ OPENAPI_TAGS = [ }, { "name": "main-menu", - "description": "Управление кнопками и сообщениями главного меню Telegram-бота.", - }, - { - "name": "welcome-texts", - "description": "Создание, редактирование и управление приветственными текстами.", + "description": "Управление кнопками главного меню Telegram-бота.", }, { "name": "users", @@ -175,16 +169,6 @@ def create_web_api_app() -> FastAPI: prefix="/main-menu/buttons", tags=["main-menu"], ) - app.include_router( - user_messages.router, - prefix="/main-menu/messages", - tags=["main-menu"], - ) - app.include_router( - welcome_texts.router, - prefix="/welcome-texts", - tags=["welcome-texts"], - ) 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"]) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index cbe4030a..4484462a 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -7,8 +7,6 @@ from . import ( partners, polls, promo_offers, - user_messages, - welcome_texts, pages, promo_groups, servers, @@ -32,8 +30,6 @@ __all__ = [ "partners", "polls", "promo_offers", - "user_messages", - "welcome_texts", "pages", "promo_groups", "servers", diff --git a/app/webapi/routes/user_messages.py b/app/webapi/routes/user_messages.py deleted file mode 100644 index d6fba008..00000000 --- a/app/webapi/routes/user_messages.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.crud.user_message import ( - create_user_message, - delete_user_message, - get_all_user_messages, - get_user_message_by_id, - get_user_messages_count, - toggle_user_message_status, - update_user_message, -) - -from ..dependencies import get_db_session, require_api_token -from ..schemas.user_messages import ( - UserMessageCreateRequest, - UserMessageListResponse, - UserMessageResponse, - UserMessageUpdateRequest, -) - -router = APIRouter() - - -def _serialize(message) -> UserMessageResponse: - return UserMessageResponse( - id=message.id, - message_text=message.message_text, - is_active=message.is_active, - sort_order=message.sort_order, - created_by=message.created_by, - created_at=message.created_at, - updated_at=message.updated_at, - ) - - -@router.get("", response_model=UserMessageListResponse) -async def list_user_messages( - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - include_inactive: bool = Query(True, description="Включать неактивные сообщения"), -) -> UserMessageListResponse: - total = await get_user_messages_count(db, include_inactive=include_inactive) - messages = await get_all_user_messages( - db, - offset=offset, - limit=limit, - include_inactive=include_inactive, - ) - - return UserMessageListResponse( - items=[_serialize(message) for message in messages], - total=total, - limit=limit, - offset=offset, - ) - - -@router.post("", response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED) -async def create_user_message_endpoint( - payload: UserMessageCreateRequest, - token: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> UserMessageResponse: - created_by = getattr(token, "id", None) - message = await create_user_message( - db, - message_text=payload.message_text, - created_by=created_by, - is_active=payload.is_active, - sort_order=payload.sort_order, - ) - - return _serialize(message) - - -@router.patch("/{message_id}", response_model=UserMessageResponse) -async def update_user_message_endpoint( - message_id: int, - payload: UserMessageUpdateRequest, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> UserMessageResponse: - update_payload = payload.dict(exclude_unset=True) - message = await update_user_message(db, message_id, **update_payload) - - if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") - - return _serialize(message) - - -@router.post("/{message_id}/toggle", response_model=UserMessageResponse) -async def toggle_user_message_endpoint( - message_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> UserMessageResponse: - message = await toggle_user_message_status(db, message_id) - - if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") - - return _serialize(message) - - -@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user_message_endpoint( - message_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> Response: - message = await get_user_message_by_id(db, message_id) - if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") - - await delete_user_message(db, message_id) - return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/routes/welcome_texts.py b/app/webapi/routes/welcome_texts.py deleted file mode 100644 index 906cc0d8..00000000 --- a/app/webapi/routes/welcome_texts.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.crud.welcome_text import ( - count_welcome_texts, - create_welcome_text, - delete_welcome_text, - get_welcome_text_by_id, - list_welcome_texts, - update_welcome_text, -) - -from ..dependencies import get_db_session, require_api_token -from ..schemas.welcome_texts import ( - WelcomeTextCreateRequest, - WelcomeTextListResponse, - WelcomeTextResponse, - WelcomeTextUpdateRequest, -) - -router = APIRouter() - - -def _serialize(text) -> WelcomeTextResponse: - return WelcomeTextResponse( - id=text.id, - text=text.text_content, - is_active=text.is_active, - is_enabled=text.is_enabled, - created_by=text.created_by, - created_at=text.created_at, - updated_at=text.updated_at, - ) - - -@router.get("", response_model=WelcomeTextListResponse) -async def list_welcome_texts_endpoint( - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - include_inactive: bool = Query(True, description="Включать неактивные тексты"), -) -> WelcomeTextListResponse: - total = await count_welcome_texts(db, include_inactive=include_inactive) - records = await list_welcome_texts( - db, - limit=limit, - offset=offset, - include_inactive=include_inactive, - ) - - return WelcomeTextListResponse( - items=[_serialize(item) for item in records], - total=total, - limit=limit, - offset=offset, - ) - - -@router.post("", response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED) -async def create_welcome_text_endpoint( - payload: WelcomeTextCreateRequest, - token: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> WelcomeTextResponse: - created_by = getattr(token, "id", None) - record = await create_welcome_text( - db, - text_content=payload.text, - created_by=created_by, - is_enabled=payload.is_enabled, - is_active=payload.is_active, - ) - - return _serialize(record) - - -@router.get("/{welcome_text_id}", response_model=WelcomeTextResponse) -async def get_welcome_text_endpoint( - welcome_text_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> WelcomeTextResponse: - record = await get_welcome_text_by_id(db, welcome_text_id) - if not record: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") - - return _serialize(record) - - -@router.patch("/{welcome_text_id}", response_model=WelcomeTextResponse) -async def update_welcome_text_endpoint( - welcome_text_id: int, - payload: WelcomeTextUpdateRequest, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> WelcomeTextResponse: - record = await get_welcome_text_by_id(db, welcome_text_id) - if not record: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") - - update_payload = payload.dict(exclude_unset=True) - updated = await update_welcome_text(db, record, **update_payload) - return _serialize(updated) - - -@router.delete("/{welcome_text_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_welcome_text_endpoint( - welcome_text_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> Response: - record = await get_welcome_text_by_id(db, welcome_text_id) - if not record: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") - - await delete_welcome_text(db, record) - return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/schemas/user_messages.py b/app/webapi/schemas/user_messages.py deleted file mode 100644 index 97f41c69..00000000 --- a/app/webapi/schemas/user_messages.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field, validator - - -def _normalize_text(value: str) -> str: - cleaned = (value or "").strip() - if not cleaned: - raise ValueError("Message text cannot be empty") - return cleaned - - -class UserMessageResponse(BaseModel): - id: int - message_text: str - is_active: bool - sort_order: int - created_by: Optional[int] - created_at: datetime - updated_at: datetime - - -class UserMessageCreateRequest(BaseModel): - message_text: str = Field(..., min_length=1, max_length=4000) - is_active: bool = True - sort_order: int = Field(0, ge=0) - - _normalize_message_text = validator("message_text", allow_reuse=True)(_normalize_text) - - -class UserMessageUpdateRequest(BaseModel): - message_text: Optional[str] = Field(None, min_length=1, max_length=4000) - is_active: Optional[bool] = None - sort_order: Optional[int] = Field(None, ge=0) - - @validator("message_text") - def validate_message_text(cls, value): # noqa: D401,B902 - if value is None: - return value - return _normalize_text(value) - - -class UserMessageListResponse(BaseModel): - items: list[UserMessageResponse] - total: int - limit: int - offset: int diff --git a/app/webapi/schemas/welcome_texts.py b/app/webapi/schemas/welcome_texts.py deleted file mode 100644 index e68c9410..00000000 --- a/app/webapi/schemas/welcome_texts.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field, validator - - -def _normalize_text(value: str) -> str: - cleaned = (value or "").strip() - if not cleaned: - raise ValueError("Text cannot be empty") - return cleaned - - -class WelcomeTextResponse(BaseModel): - id: int - text: str - is_active: bool - is_enabled: bool - created_by: Optional[int] - created_at: datetime - updated_at: datetime - - -class WelcomeTextCreateRequest(BaseModel): - text: str = Field(..., min_length=1, max_length=4000) - is_enabled: bool = True - is_active: bool = True - - _normalize_text = validator("text", allow_reuse=True)(_normalize_text) - - -class WelcomeTextUpdateRequest(BaseModel): - text: Optional[str] = Field(None, min_length=1, max_length=4000) - is_enabled: Optional[bool] = None - is_active: Optional[bool] = None - - @validator("text") - def validate_text(cls, value): # noqa: D401,B902 - if value is None: - return value - return _normalize_text(value) - - -class WelcomeTextListResponse(BaseModel): - items: list[WelcomeTextResponse] - total: int - limit: int - offset: int From 49b8a96a8442695f0ebde8f5a89e844b11b7e780 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 25 Nov 2025 01:25:41 +0300 Subject: [PATCH 5/7] Handle missing creator on welcome text creation --- app/database/crud/user_message.py | 30 +++++-- app/database/crud/welcome_text.py | 116 +++++++++++++++++++++++++- app/webapi/app.py | 18 +++- app/webapi/routes/__init__.py | 4 + app/webapi/routes/user_messages.py | 124 ++++++++++++++++++++++++++++ app/webapi/routes/welcome_texts.py | 122 +++++++++++++++++++++++++++ app/webapi/schemas/user_messages.py | 50 +++++++++++ app/webapi/schemas/welcome_texts.py | 50 +++++++++++ 8 files changed, 503 insertions(+), 11 deletions(-) create mode 100644 app/webapi/routes/user_messages.py create mode 100644 app/webapi/routes/welcome_texts.py create mode 100644 app/webapi/schemas/user_messages.py create mode 100644 app/webapi/schemas/welcome_texts.py diff --git a/app/database/crud/user_message.py b/app/database/crud/user_message.py index 488a27d0..03960eff 100644 --- a/app/database/crud/user_message.py +++ b/app/database/crud/user_message.py @@ -5,7 +5,7 @@ from typing import Optional, List from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import UserMessage +from app.database.models import User, UserMessage logger = logging.getLogger(__name__) @@ -13,15 +13,21 @@ logger = logging.getLogger(__name__) async def create_user_message( db: AsyncSession, message_text: str, - created_by: int, + created_by: Optional[int] = None, is_active: bool = True, sort_order: int = 0 ) -> UserMessage: + resolved_creator = created_by + + if created_by is not None: + result = await db.execute(select(User.id).where(User.id == created_by)) + resolved_creator = result.scalar_one_or_none() + message = UserMessage( message_text=message_text, is_active=is_active, sort_order=sort_order, - created_by=created_by + created_by=resolved_creator, ) db.add(message) @@ -61,19 +67,27 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]: async def get_all_user_messages( db: AsyncSession, offset: int = 0, - limit: int = 50 + limit: int = 50, + include_inactive: bool = True, ) -> List[UserMessage]: + query = select(UserMessage).order_by(UserMessage.created_at.desc()) + if not include_inactive: + query = query.where(UserMessage.is_active == True) + result = await db.execute( - select(UserMessage) - .order_by(UserMessage.created_at.desc()) + query .offset(offset) .limit(limit) ) return result.scalars().all() -async def get_user_messages_count(db: AsyncSession) -> int: - result = await db.execute(select(func.count(UserMessage.id))) +async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int: + query = select(func.count(UserMessage.id)) + if not include_inactive: + query = query.where(UserMessage.is_active == True) + + result = await db.execute(query) return result.scalar() diff --git a/app/database/crud/welcome_text.py b/app/database/crud/welcome_text.py index 2e1646eb..e0639586 100644 --- a/app/database/crud/welcome_text.py +++ b/app/database/crud/welcome_text.py @@ -1,10 +1,10 @@ import logging from datetime import datetime from typing import Optional -from sqlalchemy import select, update +from sqlalchemy import select, update, func from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import WelcomeText +from app.database.models import User, WelcomeText logger = logging.getLogger(__name__) @@ -45,6 +45,37 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict: 'id': None } + +async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]: + result = await db.execute( + select(WelcomeText).where(WelcomeText.id == welcome_text_id) + ) + return result.scalar_one_or_none() + + +async def list_welcome_texts( + db: AsyncSession, + *, + include_inactive: bool = True, + limit: int = 50, + offset: int = 0, +): + query = select(WelcomeText).order_by(WelcomeText.updated_at.desc()) + if not include_inactive: + query = query.where(WelcomeText.is_active == True) + + result = await db.execute(query.limit(limit).offset(offset)) + return result.scalars().all() + + +async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int: + query = select(func.count(WelcomeText.id)) + if not include_inactive: + query = query.where(WelcomeText.is_active == True) + + result = await db.execute(query) + return result.scalar() + async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool: try: result = await db.execute( @@ -113,6 +144,87 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) - await db.rollback() return False + +async def create_welcome_text( + db: AsyncSession, + *, + text_content: str, + created_by: Optional[int] = None, + is_enabled: bool = True, + is_active: bool = True, +) -> WelcomeText: + resolved_creator = created_by + + if created_by is not None: + result = await db.execute(select(User.id).where(User.id == created_by)) + resolved_creator = result.scalar_one_or_none() + + if is_active: + await db.execute(update(WelcomeText).values(is_active=False)) + + welcome_text = WelcomeText( + text_content=text_content, + is_active=is_active, + is_enabled=is_enabled, + created_by=resolved_creator, + ) + + db.add(welcome_text) + await db.commit() + await db.refresh(welcome_text) + + logger.info( + "✅ Создан приветственный текст ID %s (активный=%s, включен=%s)", + welcome_text.id, + welcome_text.is_active, + welcome_text.is_enabled, + ) + return welcome_text + + +async def update_welcome_text( + db: AsyncSession, + welcome_text: WelcomeText, + *, + text_content: Optional[str] = None, + is_enabled: Optional[bool] = None, + is_active: Optional[bool] = None, +) -> WelcomeText: + if is_active: + await db.execute( + update(WelcomeText) + .where(WelcomeText.id != welcome_text.id) + .values(is_active=False) + ) + + if text_content is not None: + welcome_text.text_content = text_content + + if is_enabled is not None: + welcome_text.is_enabled = is_enabled + + if is_active is not None: + welcome_text.is_active = is_active + + welcome_text.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(welcome_text) + + logger.info( + "📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)", + welcome_text.id, + welcome_text.is_active, + welcome_text.is_enabled, + ) + return welcome_text + + +async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None: + await db.delete(welcome_text) + await db.commit() + logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id) + async def get_current_welcome_text_or_default() -> str: return ( f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! " diff --git a/app/webapi/app.py b/app/webapi/app.py index 6e87aa69..ba58d493 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -20,6 +20,8 @@ from .routes import ( promocodes, promo_groups, promo_offers, + user_messages, + welcome_texts, pages, remnawave, servers, @@ -49,7 +51,11 @@ OPENAPI_TAGS = [ }, { "name": "main-menu", - "description": "Управление кнопками главного меню Telegram-бота.", + "description": "Управление кнопками и сообщениями главного меню Telegram-бота.", + }, + { + "name": "welcome-texts", + "description": "Создание, редактирование и управление приветственными текстами.", }, { "name": "users", @@ -169,6 +175,16 @@ def create_web_api_app() -> FastAPI: prefix="/main-menu/buttons", tags=["main-menu"], ) + app.include_router( + user_messages.router, + prefix="/main-menu/messages", + tags=["main-menu"], + ) + app.include_router( + welcome_texts.router, + prefix="/welcome-texts", + tags=["welcome-texts"], + ) 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"]) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 4484462a..cbe4030a 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -7,6 +7,8 @@ from . import ( partners, polls, promo_offers, + user_messages, + welcome_texts, pages, promo_groups, servers, @@ -30,6 +32,8 @@ __all__ = [ "partners", "polls", "promo_offers", + "user_messages", + "welcome_texts", "pages", "promo_groups", "servers", diff --git a/app/webapi/routes/user_messages.py b/app/webapi/routes/user_messages.py new file mode 100644 index 00000000..d6fba008 --- /dev/null +++ b/app/webapi/routes/user_messages.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.user_message import ( + create_user_message, + delete_user_message, + get_all_user_messages, + get_user_message_by_id, + get_user_messages_count, + toggle_user_message_status, + update_user_message, +) + +from ..dependencies import get_db_session, require_api_token +from ..schemas.user_messages import ( + UserMessageCreateRequest, + UserMessageListResponse, + UserMessageResponse, + UserMessageUpdateRequest, +) + +router = APIRouter() + + +def _serialize(message) -> UserMessageResponse: + return UserMessageResponse( + id=message.id, + message_text=message.message_text, + is_active=message.is_active, + sort_order=message.sort_order, + created_by=message.created_by, + created_at=message.created_at, + updated_at=message.updated_at, + ) + + +@router.get("", response_model=UserMessageListResponse) +async def list_user_messages( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + include_inactive: bool = Query(True, description="Включать неактивные сообщения"), +) -> UserMessageListResponse: + total = await get_user_messages_count(db, include_inactive=include_inactive) + messages = await get_all_user_messages( + db, + offset=offset, + limit=limit, + include_inactive=include_inactive, + ) + + return UserMessageListResponse( + items=[_serialize(message) for message in messages], + total=total, + limit=limit, + offset=offset, + ) + + +@router.post("", response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED) +async def create_user_message_endpoint( + payload: UserMessageCreateRequest, + token: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserMessageResponse: + created_by = getattr(token, "id", None) + message = await create_user_message( + db, + message_text=payload.message_text, + created_by=created_by, + is_active=payload.is_active, + sort_order=payload.sort_order, + ) + + return _serialize(message) + + +@router.patch("/{message_id}", response_model=UserMessageResponse) +async def update_user_message_endpoint( + message_id: int, + payload: UserMessageUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserMessageResponse: + update_payload = payload.dict(exclude_unset=True) + message = await update_user_message(db, message_id, **update_payload) + + if not message: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + + return _serialize(message) + + +@router.post("/{message_id}/toggle", response_model=UserMessageResponse) +async def toggle_user_message_endpoint( + message_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserMessageResponse: + message = await toggle_user_message_status(db, message_id) + + if not message: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + + return _serialize(message) + + +@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user_message_endpoint( + message_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + message = await get_user_message_by_id(db, message_id) + if not message: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + + await delete_user_message(db, message_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/routes/welcome_texts.py b/app/webapi/routes/welcome_texts.py new file mode 100644 index 00000000..906cc0d8 --- /dev/null +++ b/app/webapi/routes/welcome_texts.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.welcome_text import ( + count_welcome_texts, + create_welcome_text, + delete_welcome_text, + get_welcome_text_by_id, + list_welcome_texts, + update_welcome_text, +) + +from ..dependencies import get_db_session, require_api_token +from ..schemas.welcome_texts import ( + WelcomeTextCreateRequest, + WelcomeTextListResponse, + WelcomeTextResponse, + WelcomeTextUpdateRequest, +) + +router = APIRouter() + + +def _serialize(text) -> WelcomeTextResponse: + return WelcomeTextResponse( + id=text.id, + text=text.text_content, + is_active=text.is_active, + is_enabled=text.is_enabled, + created_by=text.created_by, + created_at=text.created_at, + updated_at=text.updated_at, + ) + + +@router.get("", response_model=WelcomeTextListResponse) +async def list_welcome_texts_endpoint( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + include_inactive: bool = Query(True, description="Включать неактивные тексты"), +) -> WelcomeTextListResponse: + total = await count_welcome_texts(db, include_inactive=include_inactive) + records = await list_welcome_texts( + db, + limit=limit, + offset=offset, + include_inactive=include_inactive, + ) + + return WelcomeTextListResponse( + items=[_serialize(item) for item in records], + total=total, + limit=limit, + offset=offset, + ) + + +@router.post("", response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED) +async def create_welcome_text_endpoint( + payload: WelcomeTextCreateRequest, + token: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WelcomeTextResponse: + created_by = getattr(token, "id", None) + record = await create_welcome_text( + db, + text_content=payload.text, + created_by=created_by, + is_enabled=payload.is_enabled, + is_active=payload.is_active, + ) + + return _serialize(record) + + +@router.get("/{welcome_text_id}", response_model=WelcomeTextResponse) +async def get_welcome_text_endpoint( + welcome_text_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WelcomeTextResponse: + record = await get_welcome_text_by_id(db, welcome_text_id) + if not record: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + + return _serialize(record) + + +@router.patch("/{welcome_text_id}", response_model=WelcomeTextResponse) +async def update_welcome_text_endpoint( + welcome_text_id: int, + payload: WelcomeTextUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WelcomeTextResponse: + record = await get_welcome_text_by_id(db, welcome_text_id) + if not record: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + + update_payload = payload.dict(exclude_unset=True) + updated = await update_welcome_text(db, record, **update_payload) + return _serialize(updated) + + +@router.delete("/{welcome_text_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_welcome_text_endpoint( + welcome_text_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + record = await get_welcome_text_by_id(db, welcome_text_id) + if not record: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + + await delete_welcome_text(db, record) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/schemas/user_messages.py b/app/webapi/schemas/user_messages.py new file mode 100644 index 00000000..97f41c69 --- /dev/null +++ b/app/webapi/schemas/user_messages.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, validator + + +def _normalize_text(value: str) -> str: + cleaned = (value or "").strip() + if not cleaned: + raise ValueError("Message text cannot be empty") + return cleaned + + +class UserMessageResponse(BaseModel): + id: int + message_text: str + is_active: bool + sort_order: int + created_by: Optional[int] + created_at: datetime + updated_at: datetime + + +class UserMessageCreateRequest(BaseModel): + message_text: str = Field(..., min_length=1, max_length=4000) + is_active: bool = True + sort_order: int = Field(0, ge=0) + + _normalize_message_text = validator("message_text", allow_reuse=True)(_normalize_text) + + +class UserMessageUpdateRequest(BaseModel): + message_text: Optional[str] = Field(None, min_length=1, max_length=4000) + is_active: Optional[bool] = None + sort_order: Optional[int] = Field(None, ge=0) + + @validator("message_text") + def validate_message_text(cls, value): # noqa: D401,B902 + if value is None: + return value + return _normalize_text(value) + + +class UserMessageListResponse(BaseModel): + items: list[UserMessageResponse] + total: int + limit: int + offset: int diff --git a/app/webapi/schemas/welcome_texts.py b/app/webapi/schemas/welcome_texts.py new file mode 100644 index 00000000..e68c9410 --- /dev/null +++ b/app/webapi/schemas/welcome_texts.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, validator + + +def _normalize_text(value: str) -> str: + cleaned = (value or "").strip() + if not cleaned: + raise ValueError("Text cannot be empty") + return cleaned + + +class WelcomeTextResponse(BaseModel): + id: int + text: str + is_active: bool + is_enabled: bool + created_by: Optional[int] + created_at: datetime + updated_at: datetime + + +class WelcomeTextCreateRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=4000) + is_enabled: bool = True + is_active: bool = True + + _normalize_text = validator("text", allow_reuse=True)(_normalize_text) + + +class WelcomeTextUpdateRequest(BaseModel): + text: Optional[str] = Field(None, min_length=1, max_length=4000) + is_enabled: Optional[bool] = None + is_active: Optional[bool] = None + + @validator("text") + def validate_text(cls, value): # noqa: D401,B902 + if value is None: + return value + return _normalize_text(value) + + +class WelcomeTextListResponse(BaseModel): + items: list[WelcomeTextResponse] + total: int + limit: int + offset: int From 14c89c7aab01357d529da497aa6398ddfab8bbe0 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 25 Nov 2025 01:43:07 +0300 Subject: [PATCH 6/7] Expand notification events --- app/services/admin_notification_service.py | 115 ++++++++++++++++++++- app/webapi/app.py | 5 +- app/webapi/schemas/subscription_events.py | 10 +- 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index dc633664..b562f02d 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -99,7 +99,7 @@ class AdminNotificationService: *, event_type: str, user: User, - subscription: Subscription, + subscription: Subscription | None, transaction: Transaction | None = None, amount_kopeks: int | None = None, message: str | None = None, @@ -549,11 +549,38 @@ class AdminNotificationService: promo_group: PromoGroup | None, db: AsyncSession | None = None, ) -> bool: + logger.info("Начинаем отправку уведомления о пополнении баланса") + + if db: + try: + await self._record_subscription_event( + db, + event_type="balance_topup", + user=user, + subscription=subscription, + transaction=transaction, + amount_kopeks=transaction.amount_kopeks, + message="Balance top-up", + occurred_at=transaction.completed_at or transaction.created_at, + extra={ + "status": topup_status, + "balance_before": old_balance, + "balance_after": user.balance_kopeks, + "referrer_info": referrer_info, + "promo_group_id": getattr(promo_group, "id", None), + "promo_group_name": getattr(promo_group, "name", None), + }, + ) + except Exception: + logger.error( + "Не удалось сохранить событие пополнения баланса пользователя %s", + getattr(user, "id", "unknown"), + exc_info=True, + ) + if not self._is_enabled(): return False - logger.info("Начинаем отправку уведомления о пополнении баланса") - try: logger.info("Пытаемся создать сообщение уведомления") message = self._build_balance_topup_message( @@ -718,6 +745,36 @@ class AdminNotificationService: promocode_data: Dict[str, Any], effect_description: str, ) -> bool: + try: + await self._record_subscription_event( + db, + event_type="promocode_activation", + user=user, + subscription=None, + transaction=None, + amount_kopeks=promocode_data.get("balance_bonus_kopeks"), + message="Promocode activation", + occurred_at=datetime.utcnow(), + extra={ + "code": promocode_data.get("code"), + "type": promocode_data.get("type"), + "subscription_days": promocode_data.get("subscription_days"), + "balance_bonus_kopeks": promocode_data.get("balance_bonus_kopeks"), + "description": effect_description, + "valid_until": ( + promocode_data.get("valid_until").isoformat() + if isinstance(promocode_data.get("valid_until"), datetime) + else promocode_data.get("valid_until") + ), + }, + ) + except Exception: + logger.error( + "Не удалось сохранить событие активации промокода пользователя %s", + getattr(user, "id", "unknown"), + exc_info=True, + ) + if not self._is_enabled(): return False @@ -784,6 +841,31 @@ class AdminNotificationService: campaign: AdvertisingCampaign, user: Optional[User] = None, ) -> bool: + if user: + try: + await self._record_subscription_event( + db, + event_type="referral_link_visit", + user=user, + subscription=None, + transaction=None, + amount_kopeks=None, + message="Referral link visit", + occurred_at=datetime.utcnow(), + extra={ + "campaign_id": campaign.id, + "campaign_name": campaign.name, + "start_parameter": campaign.start_parameter, + "was_registered": bool(user), + }, + ) + except Exception: + logger.error( + "Не удалось сохранить событие перехода по кампании для пользователя %s", + getattr(user, "id", "unknown"), + exc_info=True, + ) + if not self._is_enabled(): return False @@ -842,6 +924,33 @@ class AdminNotificationService: initiator: Optional[User] = None, automatic: bool = False, ) -> bool: + try: + await self._record_subscription_event( + db, + event_type="promo_group_change", + user=user, + subscription=None, + transaction=None, + message="Promo group change", + occurred_at=datetime.utcnow(), + extra={ + "old_group_id": getattr(old_group, "id", None), + "old_group_name": getattr(old_group, "name", None), + "new_group_id": new_group.id, + "new_group_name": new_group.name, + "reason": reason, + "initiator_id": getattr(initiator, "id", None), + "initiator_telegram_id": getattr(initiator, "telegram_id", None), + "automatic": automatic, + }, + ) + except Exception: + logger.error( + "Не удалось сохранить событие смены промогруппы пользователя %s", + getattr(user, "id", "unknown"), + exc_info=True, + ) + if not self._is_enabled(): return False diff --git a/app/webapi/app.py b/app/webapi/app.py index ba58d493..8c4c4dcd 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -128,8 +128,9 @@ OPENAPI_TAGS = [ { "name": "notifications", "description": ( - "Получение и просмотр уведомлений о покупках, активациях и продлениях подписок " - "для административной панели." + "Получение и просмотр уведомлений о покупках, активациях и продлениях подписок, " + "пополнениях баланса, активациях промокодов, переходах по реферальным ссылкам и " + "сменах промогрупп пользователей для административной панели." ), }, ] diff --git a/app/webapi/schemas/subscription_events.py b/app/webapi/schemas/subscription_events.py index a32b9830..309490a6 100644 --- a/app/webapi/schemas/subscription_events.py +++ b/app/webapi/schemas/subscription_events.py @@ -7,7 +7,15 @@ from pydantic import BaseModel, Field, field_validator class SubscriptionEventCreate(BaseModel): - event_type: Literal["activation", "purchase", "renewal"] + event_type: Literal[ + "activation", + "purchase", + "renewal", + "balance_topup", + "promocode_activation", + "referral_link_visit", + "promo_group_change", + ] user_id: int = Field(..., ge=1) subscription_id: Optional[int] = Field(default=None, ge=1) transaction_id: Optional[int] = Field(default=None, ge=1) From 80752c3004da64b4b983dcef2945ea0858d388d6 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 25 Nov 2025 01:53:01 +0300 Subject: [PATCH 7/7] Fix welcome text update payload mapping --- app/webapi/routes/welcome_texts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/webapi/routes/welcome_texts.py b/app/webapi/routes/welcome_texts.py index 906cc0d8..b7eac08a 100644 --- a/app/webapi/routes/welcome_texts.py +++ b/app/webapi/routes/welcome_texts.py @@ -104,6 +104,8 @@ async def update_welcome_text_endpoint( raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") update_payload = payload.dict(exclude_unset=True) + if "text" in update_payload: + update_payload["text_content"] = update_payload.pop("text") updated = await update_welcome_text(db, record, **update_payload) return _serialize(updated)