diff --git a/app/cabinet/routes/admin_campaigns.py b/app/cabinet/routes/admin_campaigns.py index 38f4289e..58ddcd72 100644 --- a/app/cabinet/routes/admin_campaigns.py +++ b/app/cabinet/routes/admin_campaigns.py @@ -1,5 +1,7 @@ """Admin routes for managing advertising campaigns in cabinet.""" +from datetime import UTC, datetime + import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select @@ -20,7 +22,9 @@ from app.database.crud.campaign import ( from app.database.crud.server_squad import get_all_server_squads from app.database.crud.tariff import get_all_tariffs from app.database.models import ( + AdvertisingCampaign, AdvertisingCampaignRegistration, + PartnerStatus, Subscription, Tariff, User, @@ -28,6 +32,7 @@ from app.database.models import ( from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.campaigns import ( + AvailablePartnerItem, CampaignCreateRequest, CampaignDetailResponse, CampaignListItem, @@ -65,6 +70,14 @@ def _get_web_link(start_parameter: str) -> str | None: return None +def _get_partner_name(campaign: AdvertisingCampaign) -> str | None: + """Get partner display name from campaign.""" + if not campaign.partner_user_id or not campaign.partner: + return None + partner = campaign.partner + return partner.first_name or partner.username or f'#{partner.id}' + + @router.get('/overview', response_model=CampaignsOverviewResponse) async def get_overview( admin: User = Depends(get_current_admin_user), @@ -140,6 +153,28 @@ async def get_available_tariffs( ] +@router.get('/available-partners', response_model=list[AvailablePartnerItem]) +async def get_available_partners( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get list of approved partners for campaign partner selector.""" + result = await db.execute( + select(User) + .where(User.partner_status == PartnerStatus.APPROVED.value) + .order_by(User.first_name, User.username) + ) + partners = result.scalars().all() + return [ + AvailablePartnerItem( + user_id=p.id, + username=p.username, + first_name=p.first_name, + ) + for p in partners + ] + + @router.get('', response_model=CampaignListResponse) async def list_campaigns( include_inactive: bool = True, @@ -166,6 +201,8 @@ async def list_campaigns( registrations_count=stats['registrations'], total_revenue_kopeks=stats['total_revenue_kopeks'], conversion_rate=stats['conversion_rate'], + partner_user_id=campaign.partner_user_id, + partner_name=_get_partner_name(campaign), created_at=campaign.created_at, ) ) @@ -209,6 +246,8 @@ async def get_campaign( tariff_id=campaign.tariff_id, tariff_duration_days=campaign.tariff_duration_days, tariff=tariff_info, + partner_user_id=campaign.partner_user_id, + partner_name=_get_partner_name(campaign), created_by=campaign.created_by, created_at=campaign.created_at, updated_at=campaign.updated_at, @@ -371,6 +410,15 @@ async def create_new_campaign( detail='Tariff not found', ) + # Validate partner exists and is approved + if request.partner_user_id is not None: + partner_user = await db.get(User, request.partner_user_id) + if not partner_user or partner_user.partner_status != 'approved': + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Partner not found or not approved', + ) + campaign = await create_campaign( db, name=request.name, @@ -385,6 +433,7 @@ async def create_new_campaign( tariff_id=request.tariff_id, tariff_duration_days=request.tariff_duration_days, is_active=request.is_active, + partner_user_id=request.partner_user_id, ) # Reload to get tariff relationship @@ -456,8 +505,26 @@ async def update_existing_campaign( if request.tariff_duration_days is not None: updates['tariff_duration_days'] = request.tariff_duration_days + # Handle partner_user_id separately (allows explicit None to unassign) + partner_changed = False + if 'partner_user_id' in request.model_fields_set: + new_partner_id = request.partner_user_id + if new_partner_id is not None: + partner_user = await db.get(User, new_partner_id) + if not partner_user or partner_user.partner_status != 'approved': + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Partner not found or not approved', + ) + campaign.partner_user_id = new_partner_id + campaign.updated_at = datetime.now(UTC) + partner_changed = True + if updates: await update_campaign(db, campaign, **updates) + elif partner_changed: + await db.commit() + await db.refresh(campaign) logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id) diff --git a/app/cabinet/routes/admin_partners.py b/app/cabinet/routes/admin_partners.py index dc35fa8b..4bee3d09 100644 --- a/app/cabinet/routes/admin_partners.py +++ b/app/cabinet/routes/admin_partners.py @@ -1,11 +1,12 @@ """Admin routes for managing partners in cabinet.""" +from datetime import UTC, datetime from typing import Literal import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field -from sqlalchemy import desc, func, select +from sqlalchemy import desc, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -529,7 +530,23 @@ async def assign_campaign( detail='Пользователь не является партнёром', ) - campaign.partner_user_id = user_id + # Atomic check-and-set to prevent race conditions + result = await db.execute( + update(AdvertisingCampaign) + .where( + AdvertisingCampaign.id == campaign_id, + or_( + AdvertisingCampaign.partner_user_id.is_(None), + AdvertisingCampaign.partner_user_id == user_id, + ), + ) + .values(partner_user_id=user_id, updated_at=datetime.now(UTC)) + ) + if result.rowcount == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Кампания уже привязана к другому партнёру', + ) await db.commit() return {'success': True} @@ -557,6 +574,7 @@ async def unassign_campaign( ) campaign.partner_user_id = None + campaign.updated_at = datetime.now(UTC) await db.commit() return {'success': True} diff --git a/app/cabinet/schemas/campaigns.py b/app/cabinet/schemas/campaigns.py index d5f98f2d..313c8411 100644 --- a/app/cabinet/schemas/campaigns.py +++ b/app/cabinet/schemas/campaigns.py @@ -27,6 +27,8 @@ class CampaignListItem(BaseModel): registrations_count: int total_revenue_kopeks: int = 0 conversion_rate: float = 0.0 + partner_user_id: int | None = None + partner_name: str | None = None created_at: datetime class Config: @@ -60,6 +62,9 @@ class CampaignDetailResponse(BaseModel): tariff_id: int | None = None tariff_duration_days: int | None = None tariff: TariffInfo | None = None + # Partner + partner_user_id: int | None = None + partner_name: str | None = None # Meta created_by: int | None = None created_at: datetime @@ -89,6 +94,8 @@ class CampaignCreateRequest(BaseModel): # Tariff bonus tariff_id: int | None = None tariff_duration_days: int | None = Field(None, ge=1) + # Partner + partner_user_id: int | None = None class CampaignUpdateRequest(BaseModel): @@ -108,6 +115,8 @@ class CampaignUpdateRequest(BaseModel): # Tariff bonus tariff_id: int | None = None tariff_duration_days: int | None = Field(None, ge=1) + # Partner + partner_user_id: int | None = None class CampaignToggleResponse(BaseModel): @@ -196,6 +205,14 @@ class CampaignsOverviewResponse(BaseModel): total_tariff_issued: int = 0 +class AvailablePartnerItem(BaseModel): + """Partner item for campaign partner selector.""" + + user_id: int + username: str | None = None + first_name: str | None = None + + class ServerSquadInfo(BaseModel): """Server squad info for campaign selection.""" diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 8b8f2f37..4132a918 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -36,6 +36,7 @@ async def create_campaign( tariff_id: int | None = None, tariff_duration_days: int | None = None, is_active: bool = True, + partner_user_id: int | None = None, ) -> AdvertisingCampaign: campaign = AdvertisingCampaign( name=name, @@ -50,6 +51,7 @@ async def create_campaign( tariff_duration_days=tariff_duration_days, created_by=created_by, is_active=is_active, + partner_user_id=partner_user_id, ) db.add(campaign) @@ -71,6 +73,7 @@ async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingC .options( selectinload(AdvertisingCampaign.registrations), selectinload(AdvertisingCampaign.tariff), + selectinload(AdvertisingCampaign.partner), ) .where(AdvertisingCampaign.id == campaign_id) ) @@ -103,6 +106,7 @@ async def get_campaigns_list( .options( selectinload(AdvertisingCampaign.registrations), selectinload(AdvertisingCampaign.tariff), + selectinload(AdvertisingCampaign.partner), ) .order_by(AdvertisingCampaign.created_at.desc()) .offset(offset) @@ -141,6 +145,7 @@ async def update_campaign( 'tariff_id', 'tariff_duration_days', 'is_active', + 'partner_user_id', } update_data = {}