Merge pull request #808 from Fr1ngg/bedolaga/add-api-endpoints-for-promo-offers

Add promo offer management endpoints
This commit is contained in:
Egor
2025-10-06 04:27:40 +03:00
committed by GitHub
7 changed files with 552 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 и синхронизации данных бота: