Merge pull request #2038 from BEDOLAGA-DEV/dev5

Апи для сообщений в меню и приветственного текста и доп уведомления
This commit is contained in:
Egor
2025-11-25 02:00:46 +03:00
committed by GitHub
10 changed files with 629 additions and 17 deletions

View File

@@ -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()

View File

@@ -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 бесплатно! "

View File

@@ -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

View File

@@ -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",
@@ -122,8 +128,9 @@ OPENAPI_TAGS = [
{
"name": "notifications",
"description": (
"Получение и просмотр уведомлений о покупках, активациях и продлениях подписок "
"для административной панели."
"Получение и просмотр уведомлений о покупках, активациях и продлениях подписок, "
"пополнениях баланса, активациях промокодов, переходах по реферальным ссылкам и "
"сменах промогрупп пользователей для административной панели."
),
},
]
@@ -169,6 +176,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"])

View File

@@ -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",

View File

@@ -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)

View File

@@ -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.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)
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)
@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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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