diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py index a5f25ead..ab90a230 100644 --- a/app/database/crud/discount_offer.py +++ b/app/database/crud/discount_offer.py @@ -2,10 +2,11 @@ from __future__ import annotations import logging from datetime import datetime, timedelta -from typing import Optional +from typing import List, Optional -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database.crud.promo_offer_log import log_promo_offer_action from app.database.models import DiscountOffer @@ -68,11 +69,67 @@ async def upsert_discount_offer( async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]: result = await db.execute( - select(DiscountOffer).where(DiscountOffer.id == offer_id) + select(DiscountOffer) + .options( + selectinload(DiscountOffer.user), + selectinload(DiscountOffer.subscription), + ) + .where(DiscountOffer.id == offer_id) ) return result.scalar_one_or_none() +async def list_discount_offers( + db: AsyncSession, + *, + offset: int = 0, + limit: int = 50, + user_id: Optional[int] = None, + notification_type: Optional[str] = None, + is_active: Optional[bool] = None, +) -> List[DiscountOffer]: + stmt = ( + select(DiscountOffer) + .options( + selectinload(DiscountOffer.user), + selectinload(DiscountOffer.subscription), + ) + .order_by(DiscountOffer.created_at.desc()) + .offset(offset) + .limit(limit) + ) + + if user_id is not None: + stmt = stmt.where(DiscountOffer.user_id == user_id) + if notification_type: + stmt = stmt.where(DiscountOffer.notification_type == notification_type) + if is_active is not None: + stmt = stmt.where(DiscountOffer.is_active == is_active) + + result = await db.execute(stmt) + return result.scalars().all() + + +async def count_discount_offers( + db: AsyncSession, + *, + user_id: Optional[int] = None, + notification_type: Optional[str] = None, + is_active: Optional[bool] = None, +) -> int: + stmt = select(func.count(DiscountOffer.id)) + + if user_id is not None: + stmt = stmt.where(DiscountOffer.user_id == user_id) + if notification_type: + stmt = stmt.where(DiscountOffer.notification_type == notification_type) + if is_active is not None: + stmt = stmt.where(DiscountOffer.is_active == is_active) + + result = await db.execute(stmt) + return int(result.scalar() or 0) + + async def mark_offer_claimed( db: AsyncSession, offer: DiscountOffer, diff --git a/app/database/crud/promo_offer_log.py b/app/database/crud/promo_offer_log.py index 42d17b01..ccb8b633 100644 --- a/app/database/crud/promo_offer_log.py +++ b/app/database/crud/promo_offer_log.py @@ -52,6 +52,11 @@ async def list_promo_offer_logs( db: AsyncSession, offset: int = 0, limit: int = 20, + *, + user_id: Optional[int] = None, + offer_id: Optional[int] = None, + action: Optional[str] = None, + source: Optional[str] = None, ) -> Tuple[List[PromoOfferLog], int]: stmt = ( select(PromoOfferLog) @@ -63,10 +68,29 @@ async def list_promo_offer_logs( .offset(offset) .limit(limit) ) + + if user_id is not None: + stmt = stmt.where(PromoOfferLog.user_id == user_id) + if offer_id is not None: + stmt = stmt.where(PromoOfferLog.offer_id == offer_id) + if action: + stmt = stmt.where(PromoOfferLog.action == action) + if source: + stmt = stmt.where(PromoOfferLog.source == source) + result = await db.execute(stmt) logs = result.scalars().all() count_stmt = select(func.count(PromoOfferLog.id)) + if user_id is not None: + count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id) + if offer_id is not None: + count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id) + if action: + count_stmt = count_stmt.where(PromoOfferLog.action == action) + if source: + count_stmt = count_stmt.where(PromoOfferLog.source == source) + total = (await db.execute(count_stmt)).scalar() or 0 return logs, total diff --git a/app/webapi/app.py b/app/webapi/app.py index 06a10569..24424a3d 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -15,6 +15,7 @@ from .routes import ( promocodes, miniapp, promo_groups, + promo_offers, remnawave, stats, subscriptions, @@ -58,6 +59,10 @@ OPENAPI_TAGS = [ "name": "promo-groups", "description": "Создание и управление промо-группами и их участниками.", }, + { + "name": "promo-offers", + "description": "Управление промо-предложениями, шаблонами и журналом событий.", + }, { "name": "auth", "description": "Управление токенами доступа к административному API.", @@ -109,6 +114,7 @@ def create_web_api_app() -> FastAPI: app.include_router(tickets.router, prefix="/tickets", tags=["support"]) app.include_router(transactions.router, prefix="/transactions", tags=["transactions"]) app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"]) + app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"]) app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"]) app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"]) app.include_router(backups.router, prefix="/backups", tags=["backups"]) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 22ed34ae..8cb74cbf 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -2,6 +2,7 @@ from . import ( config, health, miniapp, + promo_offers, promo_groups, remnawave, stats, @@ -16,6 +17,7 @@ __all__ = [ "config", "health", "miniapp", + "promo_offers", "promo_groups", "remnawave", "stats", diff --git a/app/webapi/routes/promo_offers.py b/app/webapi/routes/promo_offers.py new file mode 100644 index 00000000..5613048d --- /dev/null +++ b/app/webapi/routes/promo_offers.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.discount_offer import ( + count_discount_offers, + get_offer_by_id, + list_discount_offers, + upsert_discount_offer, +) +from app.database.crud.promo_offer_log import list_promo_offer_logs +from app.database.crud.promo_offer_template import ( + get_promo_offer_template_by_id, + list_promo_offer_templates, + update_promo_offer_template, +) +from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, Subscription, User + +from ..dependencies import get_db_session, require_api_token +from ..schemas.promo_offers import ( + PromoOfferCreateRequest, + PromoOfferListResponse, + PromoOfferLogListResponse, + PromoOfferLogOfferInfo, + PromoOfferLogResponse, + PromoOfferResponse, + PromoOfferSubscriptionInfo, + PromoOfferTemplateListResponse, + PromoOfferTemplateResponse, + PromoOfferTemplateUpdateRequest, + PromoOfferUserInfo, +) + +router = APIRouter() + + +def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]: + if not user: + return None + + return PromoOfferUserInfo( + id=user.id, + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + full_name=getattr(user, "full_name", None), + ) + + +def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[PromoOfferSubscriptionInfo]: + if not subscription: + return None + + return PromoOfferSubscriptionInfo( + id=subscription.id, + status=subscription.status, + is_trial=subscription.is_trial, + start_date=subscription.start_date, + end_date=subscription.end_date, + autopay_enabled=subscription.autopay_enabled, + ) + + +def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse: + return PromoOfferResponse( + id=offer.id, + user_id=offer.user_id, + subscription_id=offer.subscription_id, + notification_type=offer.notification_type, + discount_percent=offer.discount_percent, + bonus_amount_kopeks=offer.bonus_amount_kopeks, + expires_at=offer.expires_at, + claimed_at=offer.claimed_at, + is_active=offer.is_active, + effect_type=offer.effect_type, + extra_data=offer.extra_data or {}, + created_at=offer.created_at, + updated_at=offer.updated_at, + user=_serialize_user(getattr(offer, "user", None)), + subscription=_serialize_subscription(getattr(offer, "subscription", None)), + ) + + +def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateResponse: + return PromoOfferTemplateResponse( + id=template.id, + name=template.name, + offer_type=template.offer_type, + message_text=template.message_text, + button_text=template.button_text, + valid_hours=template.valid_hours, + discount_percent=template.discount_percent, + bonus_amount_kopeks=template.bonus_amount_kopeks, + active_discount_hours=template.active_discount_hours, + test_duration_hours=template.test_duration_hours, + test_squad_uuids=[str(uuid) for uuid in (template.test_squad_uuids or [])], + is_active=template.is_active, + created_by=template.created_by, + created_at=template.created_at, + updated_at=template.updated_at, + ) + + +def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse: + user_info = _serialize_user(getattr(entry, "user", None)) + + offer = getattr(entry, "offer", None) + offer_info: Optional[PromoOfferLogOfferInfo] = None + if offer: + offer_info = PromoOfferLogOfferInfo( + id=offer.id, + notification_type=offer.notification_type, + discount_percent=offer.discount_percent, + bonus_amount_kopeks=offer.bonus_amount_kopeks, + effect_type=offer.effect_type, + expires_at=offer.expires_at, + claimed_at=offer.claimed_at, + is_active=offer.is_active, + ) + + return PromoOfferLogResponse( + id=entry.id, + user_id=entry.user_id, + offer_id=entry.offer_id, + action=entry.action, + source=entry.source, + percent=entry.percent, + effect_type=entry.effect_type, + details=entry.details or {}, + created_at=entry.created_at, + user=user_info, + offer=offer_info, + ) + + +@router.get("", response_model=PromoOfferListResponse) +async def list_promo_offers( + _: 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), + user_id: Optional[int] = Query(None, ge=1), + notification_type: Optional[str] = Query(None, min_length=1), + is_active: Optional[bool] = Query(None), +) -> PromoOfferListResponse: + offers = await list_discount_offers( + db, + offset=offset, + limit=limit, + user_id=user_id, + notification_type=notification_type, + is_active=is_active, + ) + total = await count_discount_offers( + db, + user_id=user_id, + notification_type=notification_type, + is_active=is_active, + ) + + return PromoOfferListResponse( + items=[_serialize_offer(offer) for offer in offers], + total=total, + limit=limit, + offset=offset, + ) + + +@router.post("", response_model=PromoOfferResponse, status_code=status.HTTP_201_CREATED) +async def create_promo_offer( + payload: PromoOfferCreateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PromoOfferResponse: + if payload.discount_percent < 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative") + if payload.bonus_amount_kopeks < 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative") + if payload.valid_hours <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive") + if not payload.notification_type.strip(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty") + if not payload.effect_type.strip(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty") + + user = await db.get(User, payload.user_id) + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + + if payload.subscription_id is not None: + subscription = await db.get(Subscription, payload.subscription_id) + if not subscription: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found") + if subscription.user_id != payload.user_id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription does not belong to the user") + + offer = await upsert_discount_offer( + db, + user_id=payload.user_id, + subscription_id=payload.subscription_id, + notification_type=payload.notification_type.strip(), + discount_percent=payload.discount_percent, + bonus_amount_kopeks=payload.bonus_amount_kopeks, + valid_hours=payload.valid_hours, + effect_type=payload.effect_type, + extra_data=payload.extra_data, + ) + + await db.refresh(offer, attribute_names=["user", "subscription"]) + + return _serialize_offer(offer) + + +@router.get("/logs", response_model=PromoOfferLogListResponse) +async def get_promo_offer_logs( + _: 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), + user_id: Optional[int] = Query(None, ge=1), + offer_id: Optional[int] = Query(None, ge=1), + action: Optional[str] = Query(None, min_length=1), + source: Optional[str] = Query(None, min_length=1), +) -> PromoOfferLogListResponse: + logs, total = await list_promo_offer_logs( + db, + offset=offset, + limit=limit, + user_id=user_id, + offer_id=offer_id, + action=action, + source=source, + ) + + return PromoOfferLogListResponse( + items=[_build_log_response(entry) for entry in logs], + total=int(total), + limit=limit, + offset=offset, + ) + + +@router.get("/templates", response_model=PromoOfferTemplateListResponse) +async def list_promo_offer_templates_endpoint( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PromoOfferTemplateListResponse: + templates = await list_promo_offer_templates(db) + return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates]) + + +@router.get("/templates/{template_id}", response_model=PromoOfferTemplateResponse) +async def get_promo_offer_template_endpoint( + template_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PromoOfferTemplateResponse: + template = await get_promo_offer_template_by_id(db, template_id) + if not template: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found") + return _serialize_template(template) + + +@router.patch("/templates/{template_id}", response_model=PromoOfferTemplateResponse) +async def update_promo_offer_template_endpoint( + template_id: int, + payload: PromoOfferTemplateUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PromoOfferTemplateResponse: + template = await get_promo_offer_template_by_id(db, template_id) + if not template: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found") + + if payload.valid_hours is not None and payload.valid_hours <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive") + if payload.active_discount_hours is not None and payload.active_discount_hours <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "active_discount_hours must be positive") + if payload.test_duration_hours is not None and payload.test_duration_hours <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "test_duration_hours must be positive") + if payload.discount_percent is not None and payload.discount_percent < 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative") + if payload.bonus_amount_kopeks is not None and payload.bonus_amount_kopeks < 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative") + + if payload.test_squad_uuids is not None: + normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()] + else: + normalized_squads = None + + updated_template = await update_promo_offer_template( + db, + template, + name=payload.name, + message_text=payload.message_text, + button_text=payload.button_text, + valid_hours=payload.valid_hours, + discount_percent=payload.discount_percent, + bonus_amount_kopeks=payload.bonus_amount_kopeks, + active_discount_hours=payload.active_discount_hours, + test_duration_hours=payload.test_duration_hours, + test_squad_uuids=normalized_squads, + is_active=payload.is_active, + ) + + return _serialize_template(updated_template) + + +@router.get("/{offer_id}", response_model=PromoOfferResponse) +async def get_promo_offer_endpoint( + offer_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PromoOfferResponse: + offer = await get_offer_by_id(db, offer_id) + if not offer: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer not found") + + return _serialize_offer(offer) diff --git a/app/webapi/schemas/promo_offers.py b/app/webapi/schemas/promo_offers.py new file mode 100644 index 00000000..9724e820 --- /dev/null +++ b/app/webapi/schemas/promo_offers.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class PromoOfferUserInfo(BaseModel): + id: int + telegram_id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + full_name: Optional[str] = None + + +class PromoOfferSubscriptionInfo(BaseModel): + id: int + status: str + is_trial: bool + start_date: datetime + end_date: datetime + autopay_enabled: bool + + +class PromoOfferResponse(BaseModel): + id: int + user_id: int + subscription_id: Optional[int] = None + notification_type: str + discount_percent: int + bonus_amount_kopeks: int + expires_at: datetime + claimed_at: Optional[datetime] = None + is_active: bool + effect_type: str + extra_data: Dict[str, Any] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + user: Optional[PromoOfferUserInfo] = None + subscription: Optional[PromoOfferSubscriptionInfo] = None + + +class PromoOfferListResponse(BaseModel): + items: List[PromoOfferResponse] + total: int + limit: int + offset: int + + +class PromoOfferCreateRequest(BaseModel): + user_id: int + notification_type: str = Field(..., min_length=1) + valid_hours: int = Field(..., ge=1, description="Срок действия предложения в часах") + discount_percent: int = Field(0, ge=0) + bonus_amount_kopeks: int = Field(0, ge=0) + subscription_id: Optional[int] = None + effect_type: str = Field("percent_discount", min_length=1) + extra_data: Dict[str, Any] = Field(default_factory=dict) + + +class PromoOfferTemplateResponse(BaseModel): + id: int + name: str + offer_type: str + message_text: str + button_text: str + valid_hours: int + discount_percent: int + bonus_amount_kopeks: int + active_discount_hours: Optional[int] = None + test_duration_hours: Optional[int] = None + test_squad_uuids: List[str] + is_active: bool + created_by: Optional[int] = None + created_at: datetime + updated_at: datetime + + +class PromoOfferTemplateListResponse(BaseModel): + items: List[PromoOfferTemplateResponse] + + +class PromoOfferTemplateUpdateRequest(BaseModel): + name: Optional[str] = None + message_text: Optional[str] = None + button_text: Optional[str] = None + valid_hours: Optional[int] = Field(None, ge=1) + discount_percent: Optional[int] = Field(None, ge=0) + bonus_amount_kopeks: Optional[int] = Field(None, ge=0) + active_discount_hours: Optional[int] = Field(None, ge=1) + test_duration_hours: Optional[int] = Field(None, ge=1) + test_squad_uuids: Optional[List[str]] = None + is_active: Optional[bool] = None + + +class PromoOfferLogOfferInfo(BaseModel): + id: int + notification_type: Optional[str] = None + discount_percent: Optional[int] = None + bonus_amount_kopeks: Optional[int] = None + effect_type: Optional[str] = None + expires_at: Optional[datetime] = None + claimed_at: Optional[datetime] = None + is_active: Optional[bool] = None + + +class PromoOfferLogResponse(BaseModel): + id: int + user_id: Optional[int] = None + offer_id: Optional[int] = None + action: str + source: Optional[str] = None + percent: Optional[int] = None + effect_type: Optional[str] = None + details: Dict[str, Any] = Field(default_factory=dict) + created_at: datetime + user: Optional[PromoOfferUserInfo] = None + offer: Optional[PromoOfferLogOfferInfo] = None + + +class PromoOfferLogListResponse(BaseModel): + items: List[PromoOfferLogResponse] + total: int + limit: int + offset: int diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 049916f0..26e2534b 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -127,8 +127,18 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `POST` | `/promo-groups` | Создать промо-группу. | `PATCH` | `/promo-groups/{id}` | Обновить промо-группу. | `DELETE` | `/promo-groups/{id}` | Удалить промо-группу. +| `GET` | `/promo-offers` | Список промо-предложений с фильтрами по пользователю, статусу и типу уведомления. +| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю. +| `GET` | `/promo-offers/{id}` | Детали конкретного промо-предложения. +| `GET` | `/promo-offers/templates` | Список шаблонов промо-предложений. +| `GET` | `/promo-offers/templates/{id}` | Получить данные шаблона промо-предложения. +| `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона. +| `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения). | `GET` | `/tokens` | Управление токенами доступа. +> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку +> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций). + ### RemnaWave интеграция После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота: