diff --git a/.env.example b/.env.example index b660b146..f6c3fcff 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ # ===== TELEGRAM BOT ===== BOT_TOKEN= ADMIN_IDS= +# Ссылка на поддержку: Telegram username (например, @support) или полный URL SUPPORT_USERNAME=@support # Уведомления администраторов diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a786407c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,71 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Europe/Moscow" + open-pull-requests-limit: 10 + reviewers: + - "Fr1ngg" + assignees: + - "Fr1ngg" + commit-message: + prefix: "deps" + include: "scope" + labels: + - "dependencies" + - "python" + allow: + - dependency-type: "all" + groups: + python-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "10:00" + timezone: "Europe/Moscow" + open-pull-requests-limit: 5 + reviewers: + - "Fr1ngg" + assignees: + - "Fr1ngg" + commit-message: + prefix: "ci" + include: "scope" + labels: + - "dependencies" + - "github-actions" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + time: "09:00" + timezone: "Europe/Moscow" + open-pull-requests-limit: 3 + reviewers: + - "Fr1ngg" + assignees: + - "Fr1ngg" + commit-message: + prefix: "docker" + include: "scope" + labels: + - "dependencies" + - "docker" diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index d4a3ee11..86bda870 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -36,15 +36,15 @@ jobs: TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🏷️ Собираем релизную версию: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.4-$(git rev-parse --short HEAD)" + VERSION="v2.3.5-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🚀 Собираем версию из main: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.5-dev-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🧪 Собираем dev версию: $VERSION" else - VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.5-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "🔀 Собираем PR версию: $VERSION" fi @@ -68,7 +68,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index 957fb389..e825ae52 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -24,18 +24,15 @@ jobs: packages: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - network=host - name: Log in to Container Registry - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -50,31 +47,26 @@ jobs: if [[ $GITHUB_REF == refs/tags/* ]]; then VERSION=${GITHUB_REF#refs/tags/} - echo "🏷️ Собираем релизную версию: $VERSION" + echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.4" - echo "🚀 Собираем версию из main: $VERSION" + VERSION="v2.3.5-$(git rev-parse --short HEAD)" + echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)" - echo "🧪 Собираем dev версию: $VERSION" + VERSION="v2.3.5-dev-$(git rev-parse --short HEAD)" + echo "🧪 Building dev version: $VERSION" else - VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)" - echo "🔀 Собираем PR версию: $VERSION" + VERSION="v2.3.5-pr-$(git rev-parse --short HEAD)" + echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT # Определяем, нужно ли пушить образ if [[ "${{ github.event_name }}" == "pull_request" ]]; then - if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then - echo "should_push=true" >> $GITHUB_OUTPUT - echo "✅ PR из того же репозитория - будем пушить" - else - echo "should_push=false" >> $GITHUB_OUTPUT - echo "⚠️ PR из внешнего форка - только build без push" - fi + echo "should_push=false" >> $GITHUB_OUTPUT + echo "⚠️ PR - only build without push" else echo "should_push=true" >> $GITHUB_OUTPUT - echo "✅ Push/Tag - будем пушить" + echo "✅ Push/Tag - will push" fi - name: Extract metadata @@ -93,11 +85,11 @@ jobs: type=raw,value=${{ steps.version.outputs.version }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: ${{ steps.version.outputs.should_push }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -105,18 +97,12 @@ jobs: VERSION=${{ steps.version.outputs.version }} BUILD_DATE=${{ steps.version.outputs.build_date }} VCS_REF=${{ steps.version.outputs.short_sha }} - cache-from: | - type=gha - type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache - cache-to: | - type=gha,mode=max - type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - build-contexts: | - alpine=docker-image://alpine:latest + cache-from: type=gha + cache-to: type=gha,mode=max - name: Generate security report uses: docker/scout-action@v1 - if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true' + if: github.event_name == 'pull_request' with: command: quickview,compare image: ${{ steps.meta.outputs.tags }} @@ -125,30 +111,31 @@ jobs: only-severities: critical,high write-comment: true github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true - name: Build Summary if: steps.version.outputs.should_push == 'true' run: | echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY - echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY + echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY - echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY + echo "| **Image** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Branch** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Status** | ✅ Published |" >> $GITHUB_STEP_SUMMARY - name: Build Summary (No Push) if: steps.version.outputs.should_push == 'false' run: | echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY - echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY + echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY - echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY + echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Status** | ✅ Built successfully (without publishing) |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY + echo "⚠️ **Note:** Image built but not published as this is a pull request." >> $GITHUB_STEP_SUMMARY diff --git a/Dockerfile b/Dockerfile index d09a5920..ad2b4347 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim AS builder +FROM python:3.13-slim AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ @@ -12,9 +12,9 @@ COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -r requirements.txt -FROM python:3.11-slim +FROM python:3.13-slim -ARG VERSION="v2.3.4" +ARG VERSION="v2.3.5" ARG BUILD_DATE ARG VCS_REF diff --git a/README.md b/README.md index 6ff49d95..9f8525ff 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв # ===== TELEGRAM BOT ===== BOT_TOKEN= ADMIN_IDS= +# Ссылка на поддержку: Telegram username (например, @support) или полный URL SUPPORT_USERNAME=@support # Уведомления администраторов diff --git a/app/bot.py b/app/bot.py index aaa30c53..1be1fd65 100644 --- a/app/bot.py +++ b/app/bot.py @@ -19,15 +19,23 @@ from app.handlers import ( referral, support, common ) from app.handlers.admin import ( - main as admin_main, users as admin_users, subscriptions as admin_subscriptions, - promocodes as admin_promocodes, messages as admin_messages, - monitoring as admin_monitoring, referrals as admin_referrals, - rules as admin_rules, remnawave as admin_remnawave, - statistics as admin_statistics, servers as admin_servers, + main as admin_main, + users as admin_users, + subscriptions as admin_subscriptions, + promocodes as admin_promocodes, + messages as admin_messages, + monitoring as admin_monitoring, + referrals as admin_referrals, + rules as admin_rules, + remnawave as admin_remnawave, + statistics as admin_statistics, + servers as admin_servers, maintenance as admin_maintenance, + campaigns as admin_campaigns, user_messages as admin_user_messages, - updates as admin_updates, backup as admin_backup, - welcome_text as admin_welcome_text + updates as admin_updates, + backup as admin_backup, + welcome_text as admin_welcome_text, ) from app.handlers.stars_payments import register_stars_handlers @@ -119,6 +127,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_rules.register_handlers(dp) admin_remnawave.register_handlers(dp) admin_statistics.register_handlers(dp) + admin_campaigns.register_handlers(dp) admin_maintenance.register_handlers(dp) admin_user_messages.register_handlers(dp) admin_updates.register_handlers(dp) diff --git a/app/config.py b/app/config.py index e1176e1e..11862bef 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,6 @@ import os import re +import html from collections import defaultdict from typing import List, Optional, Union, Dict from pydantic_settings import BaseSettings @@ -605,10 +606,68 @@ class Settings(BaseSettings): def get_traffic_price(self, gb: int) -> int: packages = self.get_traffic_packages() - + for package in packages: if package["gb"] == gb and package["enabled"]: return package["price"] + + def _clean_support_contact(self) -> str: + return (self.SUPPORT_USERNAME or "").strip() + + def get_support_contact_url(self) -> Optional[str]: + contact = self._clean_support_contact() + + if not contact: + return None + + if contact.startswith(("http://", "https://", "tg://")): + return contact + + contact_without_prefix = contact.lstrip("@") + + if contact_without_prefix.startswith(("t.me/", "telegram.me/", "telegram.dog/")): + return f"https://{contact_without_prefix}" + + if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")): + return f"https://{contact}" + + if "." in contact_without_prefix: + return f"https://{contact_without_prefix}" + + if contact_without_prefix: + return f"https://t.me/{contact_without_prefix}" + + return None + + def get_support_contact_display(self) -> str: + contact = self._clean_support_contact() + + if not contact: + return "" + + if contact.startswith("@"): + return contact + + if contact.startswith(("http://", "https://", "tg://")): + return contact + + if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")): + url = self.get_support_contact_url() + return url if url else contact + + contact_without_prefix = contact.lstrip("@") + + if "." in contact_without_prefix: + url = self.get_support_contact_url() + return url if url else contact + + if re.fullmatch(r"[A-Za-z0-9_]{3,}", contact_without_prefix): + return f"@{contact_without_prefix}" + + return contact + + def get_support_contact_display_html(self) -> str: + return html.escape(self.get_support_contact_display()) enabled_packages = [pkg for pkg in packages if pkg["enabled"]] diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py new file mode 100644 index 00000000..40313434 --- /dev/null +++ b/app/database/crud/campaign.py @@ -0,0 +1,258 @@ +import logging +from datetime import datetime +from typing import Dict, List, Optional + +from sqlalchemy import and_, func, select, update, delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + AdvertisingCampaign, + AdvertisingCampaignRegistration, +) + +logger = logging.getLogger(__name__) + + +async def create_campaign( + db: AsyncSession, + *, + name: str, + start_parameter: str, + bonus_type: str, + created_by: Optional[int] = None, + balance_bonus_kopeks: int = 0, + subscription_duration_days: Optional[int] = None, + subscription_traffic_gb: Optional[int] = None, + subscription_device_limit: Optional[int] = None, + subscription_squads: Optional[List[str]] = None, +) -> AdvertisingCampaign: + campaign = AdvertisingCampaign( + name=name, + start_parameter=start_parameter, + bonus_type=bonus_type, + balance_bonus_kopeks=balance_bonus_kopeks or 0, + subscription_duration_days=subscription_duration_days, + subscription_traffic_gb=subscription_traffic_gb, + subscription_device_limit=subscription_device_limit, + subscription_squads=subscription_squads or [], + created_by=created_by, + is_active=True, + ) + + db.add(campaign) + await db.commit() + await db.refresh(campaign) + + logger.info( + "📣 Создана рекламная кампания %s (start=%s, bonus=%s)", + campaign.name, + campaign.start_parameter, + campaign.bonus_type, + ) + return campaign + + +async def get_campaign_by_id( + db: AsyncSession, campaign_id: int +) -> Optional[AdvertisingCampaign]: + result = await db.execute( + select(AdvertisingCampaign) + .options(selectinload(AdvertisingCampaign.registrations)) + .where(AdvertisingCampaign.id == campaign_id) + ) + return result.scalar_one_or_none() + + +async def get_campaign_by_start_parameter( + db: AsyncSession, + start_parameter: str, + *, + only_active: bool = False, +) -> Optional[AdvertisingCampaign]: + stmt = select(AdvertisingCampaign).where( + AdvertisingCampaign.start_parameter == start_parameter + ) + if only_active: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(True)) + + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +async def get_campaigns_list( + db: AsyncSession, + *, + offset: int = 0, + limit: int = 20, + include_inactive: bool = True, +) -> List[AdvertisingCampaign]: + stmt = ( + select(AdvertisingCampaign) + .options(selectinload(AdvertisingCampaign.registrations)) + .order_by(AdvertisingCampaign.created_at.desc()) + .offset(offset) + .limit(limit) + ) + if not include_inactive: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(True)) + + result = await db.execute(stmt) + return result.scalars().all() + + +async def get_campaigns_count( + db: AsyncSession, *, is_active: Optional[bool] = None +) -> int: + stmt = select(func.count(AdvertisingCampaign.id)) + if is_active is not None: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active)) + + result = await db.execute(stmt) + return result.scalar_one() or 0 + + +async def update_campaign( + db: AsyncSession, + campaign: AdvertisingCampaign, + **kwargs, +) -> AdvertisingCampaign: + allowed_fields = { + "name", + "start_parameter", + "bonus_type", + "balance_bonus_kopeks", + "subscription_duration_days", + "subscription_traffic_gb", + "subscription_device_limit", + "subscription_squads", + "is_active", + } + + update_data = {key: value for key, value in kwargs.items() if key in allowed_fields} + + if not update_data: + return campaign + + update_data["updated_at"] = datetime.utcnow() + + await db.execute( + update(AdvertisingCampaign) + .where(AdvertisingCampaign.id == campaign.id) + .values(**update_data) + ) + await db.commit() + await db.refresh(campaign) + + logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data) + return campaign + + +async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool: + await db.execute( + delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id) + ) + await db.commit() + logger.info("🗑️ Удалена рекламная кампания %s", campaign.name) + return True + + +async def record_campaign_registration( + db: AsyncSession, + *, + campaign_id: int, + user_id: int, + bonus_type: str, + balance_bonus_kopeks: int = 0, + subscription_duration_days: Optional[int] = None, +) -> AdvertisingCampaignRegistration: + existing = await db.execute( + select(AdvertisingCampaignRegistration).where( + and_( + AdvertisingCampaignRegistration.campaign_id == campaign_id, + AdvertisingCampaignRegistration.user_id == user_id, + ) + ) + ) + registration = existing.scalar_one_or_none() + if registration: + return registration + + registration = AdvertisingCampaignRegistration( + campaign_id=campaign_id, + user_id=user_id, + bonus_type=bonus_type, + balance_bonus_kopeks=balance_bonus_kopeks or 0, + subscription_duration_days=subscription_duration_days, + ) + db.add(registration) + await db.commit() + await db.refresh(registration) + + logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id) + return registration + + +async def get_campaign_statistics( + db: AsyncSession, + campaign_id: int, +) -> Dict[str, Optional[int]]: + result = await db.execute( + select( + func.count(AdvertisingCampaignRegistration.id), + func.coalesce( + func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 + ), + func.max(AdvertisingCampaignRegistration.created_at), + ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) + ) + count, total_balance, last_registration = result.one() + + subscription_count_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)).where( + and_( + AdvertisingCampaignRegistration.campaign_id == campaign_id, + AdvertisingCampaignRegistration.bonus_type == "subscription", + ) + ) + ) + + return { + "registrations": count or 0, + "balance_issued": total_balance or 0, + "subscription_issued": subscription_count_result.scalar() or 0, + "last_registration": last_registration, + } + + +async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]: + total = await get_campaigns_count(db) + active = await get_campaigns_count(db, is_active=True) + inactive = await get_campaigns_count(db, is_active=False) + + registrations_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)) + ) + + balance_result = await db.execute( + select( + func.coalesce( + func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 + ) + ) + ) + + subscription_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)).where( + AdvertisingCampaignRegistration.bonus_type == "subscription" + ) + ) + + return { + "total": total, + "active": active, + "inactive": inactive, + "registrations": registrations_result.scalar() or 0, + "balance_total": balance_result.scalar() or 0, + "subscription_total": subscription_result.scalar() or 0, + } diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 004c5610..ff25714b 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -216,7 +216,8 @@ async def get_users_list( offset: int = 0, limit: int = 50, search: Optional[str] = None, - status: Optional[UserStatus] = None + status: Optional[UserStatus] = None, + order_by_balance: bool = False ) -> List[User]: query = select(User).options(selectinload(User.subscription)) @@ -237,7 +238,13 @@ async def get_users_list( query = query.where(or_(*conditions)) - query = query.order_by(User.created_at.desc()).offset(offset).limit(limit) + # Сортировка по балансу в порядке убывания, если order_by_balance=True + if order_by_balance: + query = query.order_by(User.balance_kopeks.desc()) + else: + query = query.order_by(User.created_at.desc()) + + query = query.offset(offset).limit(limit) result = await db.execute(query) return result.scalars().all() diff --git a/app/database/models.py b/app/database/models.py index e12e1eb7..285eeb08 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -3,8 +3,17 @@ from typing import Optional, List from enum import Enum from sqlalchemy import ( - Column, Integer, String, DateTime, Boolean, Text, - ForeignKey, Float, JSON, BigInteger + Column, + Integer, + String, + DateTime, + Boolean, + Text, + ForeignKey, + Float, + JSON, + BigInteger, + UniqueConstraint, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -666,7 +675,7 @@ class UserMessage(Base): class WelcomeText(Base): __tablename__ = "welcome_texts" - + id = Column(Integer, primary_key=True, index=True) text_content = Column(Text, nullable=False) is_active = Column(Boolean, default=True) @@ -674,5 +683,61 @@ class WelcomeText(Base): created_by = Column(Integer, ForeignKey("users.id"), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + creator = relationship("User", backref="created_welcome_texts") + + +class AdvertisingCampaign(Base): + __tablename__ = "advertising_campaigns" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + start_parameter = Column(String(64), nullable=False, unique=True, index=True) + bonus_type = Column(String(20), nullable=False) + + balance_bonus_kopeks = Column(Integer, default=0) + + subscription_duration_days = Column(Integer, nullable=True) + subscription_traffic_gb = Column(Integer, nullable=True) + subscription_device_limit = Column(Integer, nullable=True) + subscription_squads = Column(JSON, default=list) + + is_active = Column(Boolean, default=True) + + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign") + + @property + def is_balance_bonus(self) -> bool: + return self.bonus_type == "balance" + + @property + def is_subscription_bonus(self) -> bool: + return self.bonus_type == "subscription" + + +class AdvertisingCampaignRegistration(Base): + __tablename__ = "advertising_campaign_registrations" + __table_args__ = ( + UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), + ) + + id = Column(Integer, primary_key=True, index=True) + campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + bonus_type = Column(String(20), nullable=False) + balance_bonus_kopeks = Column(Integer, default=0) + subscription_duration_days = Column(Integer, nullable=True) + + created_at = Column(DateTime, default=func.now()) + + campaign = relationship("AdvertisingCampaign", back_populates="registrations") + user = relationship("User") + + @property + def balance_bonus_rubles(self) -> float: + return (self.balance_bonus_kopeks or 0) / 100 diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py new file mode 100644 index 00000000..22ff8aac --- /dev/null +++ b/app/handlers/admin/campaigns.py @@ -0,0 +1,1699 @@ +import logging +import re +from typing import List + +from aiogram import Bot, Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.campaign import ( + create_campaign, + delete_campaign, + get_campaign_by_id, + get_campaign_by_start_parameter, + get_campaign_statistics, + get_campaigns_count, + get_campaigns_list, + get_campaigns_overview, + update_campaign, +) +from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_id +from app.database.models import User +from app.keyboards.admin import ( + get_admin_campaigns_keyboard, + get_admin_pagination_keyboard, + get_campaign_bonus_type_keyboard, + get_campaign_edit_keyboard, + get_campaign_management_keyboard, + get_confirmation_keyboard, +) +from app.localization.texts import get_texts +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + +_CAMPAIGN_PARAM_REGEX = re.compile(r"^[A-Za-z0-9_-]{3,32}$") +_CAMPAIGNS_PAGE_SIZE = 5 + + +def _format_campaign_summary(campaign, texts) -> str: + status = "🟢 Активна" if campaign.is_active else "⚪️ Выключена" + + if campaign.is_balance_bonus: + bonus_text = texts.format_price(campaign.balance_bonus_kopeks) + bonus_info = f"💰 Бонус на баланс: {bonus_text}" + else: + traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0) + bonus_info = ( + "📱 Подписка: {days} д.\n" + "🌐 Трафик: {traffic}\n" + "📱 Устройства: {devices}" + ).format( + days=campaign.subscription_duration_days or 0, + traffic=traffic_text, + devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT, + ) + + return ( + f"{campaign.name}\n" + f"Стартовый параметр: {campaign.start_parameter}\n" + f"Статус: {status}\n" + f"{bonus_info}\n" + ) + + +async def _get_bot_deep_link( + callback: types.CallbackQuery, start_parameter: str +) -> str: + bot = await callback.bot.get_me() + return f"https://t.me/{bot.username}?start={start_parameter}" + + +async def _get_bot_deep_link_from_message( + message: types.Message, start_parameter: str +) -> str: + bot = await message.bot.get_me() + return f"https://t.me/{bot.username}?start={start_parameter}" + + +def _build_campaign_servers_keyboard( + servers, + selected_uuids: List[str], + *, + toggle_prefix: str = "campaign_toggle_server_", + save_callback: str = "campaign_servers_save", + back_callback: str = "admin_campaigns", +) -> types.InlineKeyboardMarkup: + keyboard: List[List[types.InlineKeyboardButton]] = [] + + for server in servers[:20]: + is_selected = server.squad_uuid in selected_uuids + emoji = "✅" if is_selected else ("⚪" if server.is_available else "🔒") + text = f"{emoji} {server.display_name}" + keyboard.append( + [ + types.InlineKeyboardButton( + text=text, callback_data=f"{toggle_prefix}{server.id}" + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text="✅ Сохранить", callback_data=save_callback + ), + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data=back_callback + ), + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + +async def _render_campaign_edit_menu( + bot: Bot, + chat_id: int, + message_id: int, + campaign, + language: str, + *, + use_caption: bool = False, +): + texts = get_texts(language) + text = ( + "✏️ Редактирование кампании\n\n" + f"{_format_campaign_summary(campaign, texts)}\n" + "Выберите, что изменить:" + ) + + edit_kwargs = dict( + chat_id=chat_id, + message_id=message_id, + reply_markup=get_campaign_edit_keyboard( + campaign.id, + is_balance_bonus=campaign.is_balance_bonus, + language=language, + ), + parse_mode="HTML", + ) + + if use_caption: + await bot.edit_message_caption( + caption=text, + **edit_kwargs, + ) + else: + await bot.edit_message_text( + text=text, + **edit_kwargs, + ) + + +@admin_required +@error_handler +async def show_campaigns_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + overview = await get_campaigns_overview(db) + + text = ( + "📣 Рекламные кампании\n\n" + f"Всего кампаний: {overview['total']}\n" + f"Активных: {overview['active']} | Выключены: {overview['inactive']}\n" + f"Регистраций: {overview['registrations']}\n" + f"Выдано баланса: {texts.format_price(overview['balance_total'])}\n" + f"Выдано подписок: {overview['subscription_total']}" + ) + + await callback.message.edit_text( + text, + reply_markup=get_admin_campaigns_keyboard(db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaigns_overall_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + overview = await get_campaigns_overview(db) + + text = ["📊 Общая статистика кампаний\n"] + text.append(f"Всего кампаний: {overview['total']}") + text.append( + f"Активны: {overview['active']}, выключены: {overview['inactive']}" + ) + text.append(f"Всего регистраций: {overview['registrations']}") + text.append( + f"Суммарно выдано баланса: {texts.format_price(overview['balance_total'])}" + ) + text.append(f"Выдано подписок: {overview['subscription_total']}") + + await callback.message.edit_text( + "\n".join(text), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaigns_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + page = 1 + if callback.data.startswith("admin_campaigns_list_page_"): + try: + page = int(callback.data.split("_")[-1]) + except ValueError: + page = 1 + + offset = (page - 1) * _CAMPAIGNS_PAGE_SIZE + campaigns = await get_campaigns_list( + db, + offset=offset, + limit=_CAMPAIGNS_PAGE_SIZE, + ) + total = await get_campaigns_count(db) + total_pages = max(1, (total + _CAMPAIGNS_PAGE_SIZE - 1) // _CAMPAIGNS_PAGE_SIZE) + + if not campaigns: + await callback.message.edit_text( + "❌ Рекламные кампании не найдены.", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="➕ Создать", callback_data="admin_campaigns_create" + ) + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ], + ] + ), + ) + await callback.answer() + return + + text_lines = ["📋 Список кампаний\n"] + + for campaign in campaigns: + registrations = len(campaign.registrations or []) + total_balance = sum( + r.balance_bonus_kopeks or 0 for r in campaign.registrations or [] + ) + status = "🟢" if campaign.is_active else "⚪" + line = ( + f"{status} {campaign.name}{campaign.start_parameter}\n" + f" Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}" + ) + if campaign.is_subscription_bonus: + line += f", подписка: {campaign.subscription_duration_days or 0} д." + else: + line += ", бонус: баланс" + text_lines.append(line) + + keyboard_rows = [ + [ + types.InlineKeyboardButton( + text=f"🔍 {campaign.name}", + callback_data=f"admin_campaign_manage_{campaign.id}", + ) + ] + for campaign in campaigns + ] + + pagination = get_admin_pagination_keyboard( + current_page=page, + total_pages=total_pages, + callback_prefix="admin_campaigns_list", + back_callback="admin_campaigns", + language=db_user.language, + ) + + keyboard_rows.extend(pagination.inline_keyboard) + + await callback.message.edit_text( + "\n".join(text_lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaign_detail( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + texts = get_texts(db_user.language) + stats = await get_campaign_statistics(db, campaign_id) + deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) + + text = ["📣 Управление кампанией\n"] + text.append(_format_campaign_summary(campaign, texts)) + text.append(f"🔗 Ссылка: {deep_link}") + text.append("\n📊 Статистика") + text.append(f"• Регистраций: {stats['registrations']}") + text.append( + f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" + ) + text.append(f"• Выдано подписок: {stats['subscription_issued']}") + if stats["last_registration"]: + text.append( + f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" + ) + + await callback.message.edit_text( + "\n".join(text), + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaign_edit_menu( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + + if not campaign: + await state.clear() + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await state.clear() + + use_caption = bool(callback.message.caption) and not bool(callback.message.text) + + await _render_campaign_edit_menu( + callback.bot, + callback.message.chat.id, + callback.message.message_id, + campaign, + db_user.language, + use_caption=use_caption, + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_campaign_name( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_name) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, + ) + + await callback.message.edit_text( + ( + "✏️ Изменение названия кампании\n\n" + f"Текущее название: {campaign.name}\n" + "Введите новое название (3-100 символов):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_name( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + new_name = message.text.strip() + if len(new_name) < 3 or len(new_name) > 100: + await message.answer( + "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." + ) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + await update_campaign(db, campaign, name=new_name) + await state.clear() + + await message.answer("✅ Название обновлено.") + + edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + use_caption=edit_message_is_caption, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_start_parameter( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_start) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, + ) + + await callback.message.edit_text( + ( + "🔗 Изменение стартового параметра\n\n" + f"Текущий параметр: {campaign.start_parameter}\n" + "Введите новый параметр (латинские буквы, цифры, - или _, 3-32 символа):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_start_parameter( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + new_param = message.text.strip() + if not _CAMPAIGN_PARAM_REGEX.match(new_param): + await message.answer( + "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." + ) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + existing = await get_campaign_by_start_parameter(db, new_param) + if existing and existing.id != campaign_id: + await message.answer("❌ Такой параметр уже используется. Введите другой вариант.") + return + + await update_campaign(db, campaign, start_parameter=new_param) + await state.clear() + + await message.answer("✅ Стартовый параметр обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + use_caption=edit_message_is_caption, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_balance_bonus( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not campaign.is_balance_bonus: + await callback.answer("❌ У кампании другой тип бонуса", show_alert=True) + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_balance) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, + ) + + await callback.message.edit_text( + ( + "💰 Изменение бонуса на баланс\n\n" + f"Текущий бонус: {get_texts(db_user.language).format_price(campaign.balance_bonus_kopeks)}\n" + "Введите новую сумму в рублях (например, 100 или 99.5):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_balance_bonus( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + amount_rubles = float(message.text.replace(",", ".")) + except ValueError: + await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") + return + + if amount_rubles <= 0: + await message.answer("❌ Сумма должна быть больше нуля") + return + + amount_kopeks = int(round(amount_rubles * 100)) + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not campaign.is_balance_bonus: + await message.answer("❌ У кампании другой тип бонуса") + await state.clear() + return + + await update_campaign(db, campaign, balance_bonus_kopeks=amount_kopeks) + await state.clear() + + await message.answer("✅ Бонус обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + use_caption=edit_message_is_caption, + ) + + +async def _ensure_subscription_campaign(message_or_callback, campaign) -> bool: + if campaign.is_balance_bonus: + if isinstance(message_or_callback, types.CallbackQuery): + await message_or_callback.answer( + "❌ Для этой кампании доступен только бонус на баланс", + show_alert=True, + ) + else: + await message_or_callback.answer( + "❌ Для этой кампании нельзя изменить параметры подписки" + ) + return False + return True + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_days( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_days) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, + ) + + await callback.message.edit_text( + ( + "📅 Изменение длительности подписки\n\n" + f"Текущее значение: {campaign.subscription_duration_days or 0} д.\n" + "Введите новое количество дней (1-730):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_subscription_days( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + days = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите число дней (1-730)") + return + + if days <= 0 or days > 730: + await message.answer("❌ Длительность должна быть от 1 до 730 дней") + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not await _ensure_subscription_campaign(message, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_duration_days=days) + await state.clear() + + await message.answer("✅ Длительность подписки обновлена.") + + edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + use_caption=edit_message_is_caption, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_traffic( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_traffic) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, + ) + + current_traffic = campaign.subscription_traffic_gb or 0 + traffic_text = "безлимит" if current_traffic == 0 else f"{current_traffic} ГБ" + + await callback.message.edit_text( + ( + "🌐 Изменение лимита трафика\n\n" + f"Текущее значение: {traffic_text}\n" + "Введите новый лимит в ГБ (0 = безлимит, максимум 10000):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_subscription_traffic( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + traffic = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число (0 или больше)") + return + + if traffic < 0 or traffic > 10000: + await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not await _ensure_subscription_campaign(message, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_traffic_gb=traffic) + await state.clear() + + await message.answer("✅ Лимит трафика обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + use_caption=edit_message_is_caption, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_devices( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_devices) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, + ) + + current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT + + await callback.message.edit_text( + ( + "📱 Изменение лимита устройств\n\n" + f"Текущее значение: {current_devices}\n" + f"Введите новое количество (1-{settings.MAX_DEVICES_LIMIT}):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_subscription_devices( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + devices = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число устройств") + return + + if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: + await message.answer( + f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" + ) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not await _ensure_subscription_campaign(message, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_device_limit=devices) + await state.clear() + + await message.answer("✅ Лимит устройств обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + use_caption=edit_message_is_caption, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_servers( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + servers, _ = await get_all_server_squads(db, available_only=False) + if not servers: + await callback.answer( + "❌ Не найдены доступные серверы. Добавьте серверы перед изменением.", + show_alert=True, + ) + return + + selected = list(campaign.subscription_squads or []) + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_servers) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + campaign_subscription_squads=selected, + campaign_edit_message_is_caption=is_caption, + ) + + keyboard = _build_campaign_servers_keyboard( + servers, + selected, + toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", + save_callback=f"campaign_edit_servers_save_{campaign_id}", + back_callback=f"admin_campaign_edit_{campaign_id}", + ) + + await callback.message.edit_text( + ( + "🌍 Редактирование доступных серверов\n\n" + "Нажмите на сервер, чтобы добавить или убрать его из кампании.\n" + "После выбора нажмите \"✅ Сохранить\"." + ), + reply_markup=keyboard, + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_edit_campaign_server( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + parts = callback.data.split("_") + try: + server_id = int(parts[-1]) + except (ValueError, IndexError): + await callback.answer("❌ Не удалось определить сервер", show_alert=True) + return + + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await callback.answer("❌ Сессия редактирования устарела", show_alert=True) + await state.clear() + return + + server = await get_server_squad_by_id(db, server_id) + if not server: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + selected = list(data.get("campaign_subscription_squads", [])) + + if server.squad_uuid in selected: + selected.remove(server.squad_uuid) + else: + selected.append(server.squad_uuid) + + await state.update_data(campaign_subscription_squads=selected) + + servers, _ = await get_all_server_squads(db, available_only=False) + keyboard = _build_campaign_servers_keyboard( + servers, + selected, + toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", + save_callback=f"campaign_edit_servers_save_{campaign_id}", + back_callback=f"admin_campaign_edit_{campaign_id}", + ) + + await callback.message.edit_reply_markup(reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def save_edit_campaign_subscription_servers( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await callback.answer("❌ Сессия редактирования устарела", show_alert=True) + await state.clear() + return + + selected = list(data.get("campaign_subscription_squads", [])) + if not selected: + await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await state.clear() + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_squads=selected) + await state.clear() + + use_caption = bool(callback.message.caption) and not bool(callback.message.text) + + await _render_campaign_edit_menu( + callback.bot, + callback.message.chat.id, + callback.message.message_id, + campaign, + db_user.language, + use_caption=use_caption, + ) + await callback.answer("✅ Сохранено") + + +@admin_required +@error_handler +async def toggle_campaign_status( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + new_status = not campaign.is_active + await update_campaign(db, campaign, is_active=new_status) + status_text = "включена" if new_status else "выключена" + logger.info("🔄 Кампания %s переключена: %s", campaign_id, status_text) + + await show_campaign_detail(callback, db_user, db) + + +@admin_required +@error_handler +async def show_campaign_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + texts = get_texts(db_user.language) + stats = await get_campaign_statistics(db, campaign_id) + + text = ["📊 Статистика кампании\n"] + text.append(_format_campaign_summary(campaign, texts)) + text.append(f"Регистраций: {stats['registrations']}") + text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}") + text.append(f"Выдано подписок: {stats['subscription_issued']}") + if stats["last_registration"]: + text.append( + f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" + ) + + await callback.message.edit_text( + "\n".join(text), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=f"admin_campaign_manage_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def confirm_delete_campaign( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + text = ( + "🗑️ Удаление кампании\n\n" + f"Название: {campaign.name}\n" + f"Параметр: {campaign.start_parameter}\n\n" + "Вы уверены, что хотите удалить кампанию?" + ) + + await callback.message.edit_text( + text, + reply_markup=get_confirmation_keyboard( + confirm_action=f"admin_campaign_delete_confirm_{campaign_id}", + cancel_action=f"admin_campaign_manage_{campaign_id}", + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_campaign_confirmed( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await delete_campaign(db, campaign) + await callback.message.edit_text( + "✅ Кампания удалена.", + reply_markup=get_admin_campaigns_keyboard(db_user.language), + ) + await callback.answer("Удалено") + + +@admin_required +@error_handler +async def start_campaign_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + await state.clear() + await callback.message.edit_text( + "🆕 Создание рекламной кампании\n\nВведите название кампании:", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await state.set_state(AdminStates.creating_campaign_name) + await callback.answer() + + +@admin_required +@error_handler +async def process_campaign_name( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + name = message.text.strip() + if len(name) < 3 or len(name) > 100: + await message.answer( + "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." + ) + return + + await state.update_data(campaign_name=name) + await state.set_state(AdminStates.creating_campaign_start) + await message.answer( + "🔗 Теперь введите параметр старта (латинские буквы, цифры, - или _):", + ) + + +@admin_required +@error_handler +async def process_campaign_start_parameter( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + start_param = message.text.strip() + if not _CAMPAIGN_PARAM_REGEX.match(start_param): + await message.answer( + "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." + ) + return + + existing = await get_campaign_by_start_parameter(db, start_param) + if existing: + await message.answer( + "❌ Кампания с таким параметром уже существует. Введите другой параметр." + ) + return + + await state.update_data(campaign_start_parameter=start_param) + await state.set_state(AdminStates.creating_campaign_bonus) + await message.answer( + "🎯 Выберите тип бонуса для кампании:", + reply_markup=get_campaign_bonus_type_keyboard(db_user.language), + ) + + +@admin_required +@error_handler +async def select_campaign_bonus_type( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + bonus_type = "balance" if callback.data.endswith("balance") else "subscription" + await state.update_data(campaign_bonus_type=bonus_type) + + if bonus_type == "balance": + await state.set_state(AdminStates.creating_campaign_balance) + await callback.message.edit_text( + "💰 Введите сумму бонуса на баланс (в рублях):", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + else: + await state.set_state(AdminStates.creating_campaign_subscription_days) + await callback.message.edit_text( + "📅 Введите длительность подписки в днях (1-730):", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_campaign_balance_value( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + amount_rubles = float(message.text.replace(",", ".")) + except ValueError: + await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") + return + + if amount_rubles <= 0: + await message.answer("❌ Сумма должна быть больше нуля") + return + + amount_kopeks = int(round(amount_rubles * 100)) + data = await state.get_data() + + campaign = await create_campaign( + db, + name=data["campaign_name"], + start_parameter=data["campaign_start_parameter"], + bonus_type="balance", + balance_bonus_kopeks=amount_kopeks, + created_by=db_user.id, + ) + + await state.clear() + + deep_link = await _get_bot_deep_link_from_message(message, campaign.start_parameter) + texts = get_texts(db_user.language) + summary = _format_campaign_summary(campaign, texts) + text = ( + "✅ Кампания создана!\n\n" + f"{summary}\n" + f"🔗 Ссылка: {deep_link}" + ) + + await message.answer( + text, + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + + +@admin_required +@error_handler +async def process_campaign_subscription_days( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + days = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите число дней (1-730)") + return + + if days <= 0 or days > 730: + await message.answer("❌ Длительность должна быть от 1 до 730 дней") + return + + await state.update_data(campaign_subscription_days=days) + await state.set_state(AdminStates.creating_campaign_subscription_traffic) + await message.answer("🌐 Введите лимит трафика в ГБ (0 = безлимит):") + + +@admin_required +@error_handler +async def process_campaign_subscription_traffic( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + traffic = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число (0 или больше)") + return + + if traffic < 0 or traffic > 10000: + await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") + return + + await state.update_data(campaign_subscription_traffic=traffic) + await state.set_state(AdminStates.creating_campaign_subscription_devices) + await message.answer( + f"📱 Введите количество устройств (1-{settings.MAX_DEVICES_LIMIT}):" + ) + + +@admin_required +@error_handler +async def process_campaign_subscription_devices( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + devices = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число устройств") + return + + if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: + await message.answer( + f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" + ) + return + + await state.update_data(campaign_subscription_devices=devices) + await state.update_data(campaign_subscription_squads=[]) + await state.set_state(AdminStates.creating_campaign_subscription_servers) + + servers, _ = await get_all_server_squads(db, available_only=False) + if not servers: + await message.answer( + "❌ Не найдены доступные серверы. Добавьте сервера перед созданием кампании.", + ) + await state.clear() + return + + keyboard = _build_campaign_servers_keyboard(servers, []) + await message.answer( + "🌍 Выберите серверы, которые будут доступны по подписке (максимум 20 отображаются).", + reply_markup=keyboard, + ) + + +@admin_required +@error_handler +async def toggle_campaign_server( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + server_id = int(callback.data.split("_")[-1]) + server = await get_server_squad_by_id(db, server_id) + if not server: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + data = await state.get_data() + selected = list(data.get("campaign_subscription_squads", [])) + + if server.squad_uuid in selected: + selected.remove(server.squad_uuid) + else: + selected.append(server.squad_uuid) + + await state.update_data(campaign_subscription_squads=selected) + + servers, _ = await get_all_server_squads(db, available_only=False) + keyboard = _build_campaign_servers_keyboard(servers, selected) + + await callback.message.edit_reply_markup(reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def finalize_campaign_subscription( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + selected = data.get("campaign_subscription_squads", []) + + if not selected: + await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) + return + + campaign = await create_campaign( + db, + name=data["campaign_name"], + start_parameter=data["campaign_start_parameter"], + bonus_type="subscription", + subscription_duration_days=data.get("campaign_subscription_days"), + subscription_traffic_gb=data.get("campaign_subscription_traffic"), + subscription_device_limit=data.get("campaign_subscription_devices"), + subscription_squads=selected, + created_by=db_user.id, + ) + + await state.clear() + + deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) + texts = get_texts(db_user.language) + summary = _format_campaign_summary(campaign, texts) + text = ( + "✅ Кампания создана!\n\n" + f"{summary}\n" + f"🔗 Ссылка: {deep_link}" + ) + + await callback.message.edit_text( + text, + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_campaigns_menu, F.data == "admin_campaigns") + dp.callback_query.register( + show_campaigns_overall_stats, F.data == "admin_campaigns_stats" + ) + dp.callback_query.register(show_campaigns_list, F.data == "admin_campaigns_list") + dp.callback_query.register( + show_campaigns_list, F.data.startswith("admin_campaigns_list_page_") + ) + dp.callback_query.register( + start_campaign_creation, F.data == "admin_campaigns_create" + ) + dp.callback_query.register( + show_campaign_stats, F.data.startswith("admin_campaign_stats_") + ) + dp.callback_query.register( + show_campaign_detail, F.data.startswith("admin_campaign_manage_") + ) + dp.callback_query.register( + start_edit_campaign_name, F.data.startswith("admin_campaign_edit_name_") + ) + dp.callback_query.register( + start_edit_campaign_start_parameter, + F.data.startswith("admin_campaign_edit_start_"), + ) + dp.callback_query.register( + start_edit_campaign_balance_bonus, + F.data.startswith("admin_campaign_edit_balance_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_days, + F.data.startswith("admin_campaign_edit_sub_days_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_traffic, + F.data.startswith("admin_campaign_edit_sub_traffic_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_devices, + F.data.startswith("admin_campaign_edit_sub_devices_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_servers, + F.data.startswith("admin_campaign_edit_sub_servers_"), + ) + dp.callback_query.register( + save_edit_campaign_subscription_servers, + F.data.startswith("campaign_edit_servers_save_"), + ) + dp.callback_query.register( + toggle_edit_campaign_server, F.data.startswith("campaign_edit_toggle_") + ) + dp.callback_query.register( + show_campaign_edit_menu, F.data.startswith("admin_campaign_edit_") + ) + dp.callback_query.register( + delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_") + ) + dp.callback_query.register( + confirm_delete_campaign, F.data.startswith("admin_campaign_delete_") + ) + dp.callback_query.register( + toggle_campaign_status, F.data.startswith("admin_campaign_toggle_") + ) + dp.callback_query.register( + finalize_campaign_subscription, F.data == "campaign_servers_save" + ) + dp.callback_query.register( + toggle_campaign_server, F.data.startswith("campaign_toggle_server_") + ) + dp.callback_query.register( + select_campaign_bonus_type, F.data.startswith("campaign_bonus_") + ) + + dp.message.register(process_campaign_name, AdminStates.creating_campaign_name) + dp.message.register( + process_campaign_start_parameter, AdminStates.creating_campaign_start + ) + dp.message.register( + process_campaign_balance_value, AdminStates.creating_campaign_balance + ) + dp.message.register( + process_campaign_subscription_days, + AdminStates.creating_campaign_subscription_days, + ) + dp.message.register( + process_campaign_subscription_traffic, + AdminStates.creating_campaign_subscription_traffic, + ) + dp.message.register( + process_campaign_subscription_devices, + AdminStates.creating_campaign_subscription_devices, + ) + dp.message.register( + process_edit_campaign_name, AdminStates.editing_campaign_name + ) + dp.message.register( + process_edit_campaign_start_parameter, + AdminStates.editing_campaign_start, + ) + dp.message.register( + process_edit_campaign_balance_bonus, + AdminStates.editing_campaign_balance, + ) + dp.message.register( + process_edit_campaign_subscription_days, + AdminStates.editing_campaign_subscription_days, + ) + dp.message.register( + process_edit_campaign_subscription_traffic, + AdminStates.editing_campaign_subscription_traffic, + ) + dp.message.register( + process_edit_campaign_subscription_devices, + AdminStates.editing_campaign_subscription_devices, + ) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 9b7a9c2a..a88a0650 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -7,17 +7,19 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates -from app.database.models import User, UserStatus, Subscription +from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType from app.database.crud.user import get_user_by_id from app.keyboards.admin import ( get_admin_users_keyboard, get_user_management_keyboard, - get_admin_pagination_keyboard, get_confirmation_keyboard + get_admin_pagination_keyboard, get_confirmation_keyboard, + get_admin_users_filters_keyboard ) from app.localization.texts import get_texts from app.services.user_service import UserService from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago from app.services.remnawave_service import RemnaWaveService +from app.external.remnawave_api import TrafficLimitStrategy from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id logger = logging.getLogger(__name__) @@ -57,15 +59,36 @@ async def show_users_menu( await callback.answer() +@admin_required +@error_handler +async def show_users_filters( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + + text = "⚙️ Фильтры пользователей\n\nВыберите фильтр для отображения пользователей:" + + await callback.message.edit_text( + text, + reply_markup=get_admin_users_filters_keyboard(db_user.language) + ) + await callback.answer() + + @admin_required @error_handler async def show_users_list( callback: types.CallbackQuery, db_user: User, db: AsyncSession, + state: FSMContext, page: int = 1 ): + # Сбрасываем состояние, так как мы в обычном списке + await state.set_state(None) + user_service = UserService() users_data = await user_service.get_users_page(db, page=page, limit=10) @@ -151,20 +174,139 @@ async def show_users_list( await callback.answer() +@admin_required +@error_handler +async def show_users_list_by_balance( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + page: int = 1 +): + + # Устанавливаем состояние, чтобы отслеживать, откуда пришел пользователь + await state.set_state(AdminStates.viewing_user_from_balance_list) + + user_service = UserService() + users_data = await user_service.get_users_page(db, page=page, limit=10, order_by_balance=True) + + if not users_data["users"]: + await callback.message.edit_text( + "👥 Пользователи не найдены", + reply_markup=get_admin_users_keyboard(db_user.language) + ) + await callback.answer() + return + + text = f"👥 Список пользователей по балансу (стр. {page}/{users_data['total_pages']})\n\n" + text += "Нажмите на пользователя для управления:" + + keyboard = [] + + for user in users_data["users"]: + if user.status == UserStatus.ACTIVE.value: + status_emoji = "✅" + elif user.status == UserStatus.BLOCKED.value: + status_emoji = "🚫" + else: + status_emoji = "🗑️" + + subscription_emoji = "" + if user.subscription: + if user.subscription.is_trial: + subscription_emoji = "🎁" + elif user.subscription.is_active: + subscription_emoji = "💎" + else: + subscription_emoji = "⏰" + else: + subscription_emoji = "❌" + + button_text = f"{status_emoji} {subscription_emoji} {user.full_name}" + + if user.balance_kopeks > 0: + button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" + + # Добавляем дату окончания подписки, если есть подписка + if user.subscription and user.subscription.end_date: + days_left = (user.subscription.end_date - datetime.utcnow()).days + button_text += f" | 📅 {days_left}д" + + if len(button_text) > 60: + short_name = user.full_name + if len(short_name) > 20: + short_name = short_name[:17] + "..." + + button_text = f"{status_emoji} {subscription_emoji} {short_name}" + if user.balance_kopeks > 0: + button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" + + keyboard.append([ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_user_manage_{user.id}" + ) + ]) + + if users_data["total_pages"] > 1: + pagination_row = get_admin_pagination_keyboard( + users_data["current_page"], + users_data["total_pages"], + "admin_users_balance_list", + "admin_users", + db_user.language + ).inline_keyboard[0] + keyboard.append(pagination_row) + + keyboard.extend([ + [ + types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"), + types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats") + ], + [ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") + ] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + @admin_required @error_handler async def handle_users_list_pagination_fixed( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): try: callback_parts = callback.data.split('_') page = int(callback_parts[-1]) - await show_users_list(callback, db_user, db, page) + await show_users_list(callback, db_user, db, state, page) except (ValueError, IndexError) as e: logger.error(f"Ошибка парсинга номера страницы: {e}") - await show_users_list(callback, db_user, db, 1) + await show_users_list(callback, db_user, db, state, 1) + + +@admin_required +@error_handler +async def handle_users_balance_list_pagination( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + try: + callback_parts = callback.data.split('_') + page = int(callback_parts[-1]) + await show_users_list_by_balance(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_list_by_balance(callback, db_user, db, state, 1) @admin_required @@ -270,6 +412,148 @@ async def show_users_statistics( await callback.answer() +async def _render_user_subscription_overview( + callback: types.CallbackQuery, + db: AsyncSession, + user_id: int +) -> bool: + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return False + + user = profile["user"] + subscription = profile["subscription"] + + text = "📱 Подписка и настройки пользователя\n\n" + text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" + + keyboard = [] + + if subscription: + status_emoji = "✅" if subscription.is_active else "❌" + type_emoji = "🎁" if subscription.is_trial else "💎" + + traffic_display = f"{subscription.traffic_used_gb:.1f}/" + if subscription.traffic_limit_gb == 0: + traffic_display += "♾️ ГБ" + else: + traffic_display += f"{subscription.traffic_limit_gb} ГБ" + + text += f"Статус: {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n" + text += f"Тип: {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n" + text += f"Начало: {format_datetime(subscription.start_date)}\n" + text += f"Окончание: {format_datetime(subscription.end_date)}\n" + text += f"Трафик: {traffic_display}\n" + text += f"Устройства: {subscription.device_limit}\n" + + if subscription.is_active: + days_left = (subscription.end_date - datetime.utcnow()).days + text += f"Осталось дней: {days_left}\n" + + current_squads = subscription.connected_squads or [] + if current_squads: + text += "\nПодключенные серверы:\n" + for squad_uuid in current_squads: + try: + server = await get_server_squad_by_uuid(db, squad_uuid) + if server: + text += f"• {server.display_name}\n" + else: + text += f"• {squad_uuid[:8]}... (неизвестный)\n" + except Exception as e: + logger.error(f"Ошибка получения сервера {squad_uuid}: {e}") + text += f"• {squad_uuid[:8]}... (ошибка загрузки)\n" + else: + text += "\nПодключенные серверы: отсутствуют\n" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="⏰ Продлить", + callback_data=f"admin_sub_extend_{user_id}" + ), + types.InlineKeyboardButton( + text="💳 Купить подписку", + callback_data=f"admin_sub_buy_{user_id}" + ) + ], + [ + types.InlineKeyboardButton( + text="🔄 Тип подписки", + callback_data=f"admin_sub_change_type_{user_id}" + ), + types.InlineKeyboardButton( + text="📊 Добавить трафик", + callback_data=f"admin_sub_traffic_{user_id}" + ) + ], + [ + types.InlineKeyboardButton( + text="🌍 Сменить сервер", + callback_data=f"admin_user_change_server_{user_id}" + ), + types.InlineKeyboardButton( + text="📱 Устройства", + callback_data=f"admin_user_devices_{user_id}" + ) + ], + [ + types.InlineKeyboardButton( + text="🛠️ Лимит трафика", + callback_data=f"admin_user_traffic_{user_id}" + ), + types.InlineKeyboardButton( + text="🔄 Сбросить устройства", + callback_data=f"admin_user_reset_devices_{user_id}" + ) + ] + ] + + if subscription.is_active: + keyboard.append([ + types.InlineKeyboardButton( + text="🚫 Деактивировать", + callback_data=f"admin_sub_deactivate_{user_id}" + ) + ]) + else: + keyboard.append([ + types.InlineKeyboardButton( + text="✅ Активировать", + callback_data=f"admin_sub_activate_{user_id}" + ) + ]) + else: + text += "❌ Подписка отсутствует\n\n" + text += "Пользователь еще не активировал подписку." + + keyboard = [ + [ + types.InlineKeyboardButton( + text="🎁 Выдать триал", + callback_data=f"admin_sub_grant_trial_{user_id}" + ), + types.InlineKeyboardButton( + text="💎 Выдать подписку", + callback_data=f"admin_sub_grant_{user_id}" + ) + ] + ] + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + return True + + @admin_required @error_handler async def show_user_subscription( @@ -277,97 +561,11 @@ async def show_user_subscription( db_user: User, db: AsyncSession ): - + user_id = int(callback.data.split('_')[-1]) - - user_service = UserService() - profile = await user_service.get_user_profile(db, user_id) - - if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) - return - - user = profile["user"] - subscription = profile["subscription"] - - text = f"📱 Подписка пользователя\n\n" - text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" - - if subscription: - status_emoji = "✅" if subscription.is_active else "❌" - type_emoji = "🎁" if subscription.is_trial else "💎" - - text += f"Статус: {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n" - text += f"Тип: {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n" - text += f"Начало: {format_datetime(subscription.start_date)}\n" - text += f"Окончание: {format_datetime(subscription.end_date)}\n" - text += f"Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n" - text += f"Устройства: {subscription.device_limit}\n" - text += f"Подключенных устройств: {subscription.device_limit}\n" - - if subscription.is_active: - days_left = (subscription.end_date - datetime.utcnow()).days - text += f"Осталось дней: {days_left}\n" - - keyboard = [ - [ - types.InlineKeyboardButton( - text="⏰ Продлить", - callback_data=f"admin_sub_extend_{user_id}" - ), - types.InlineKeyboardButton( - text="📊 Трафик", - callback_data=f"admin_sub_traffic_{user_id}" - ) - ], - [ - types.InlineKeyboardButton( - text="🔄 Тип подписки", - callback_data=f"admin_sub_change_type_{user_id}" - ) - ] - ] - - if subscription.is_active: - keyboard.append([ - types.InlineKeyboardButton( - text="🚫 Деактивировать", - callback_data=f"admin_sub_deactivate_{user_id}" - ) - ]) - else: - keyboard.append([ - types.InlineKeyboardButton( - text="✅ Активировать", - callback_data=f"admin_sub_activate_{user_id}" - ) - ]) - else: - text += "❌ Подписка отсутствует\n\n" - text += "Пользователь еще не активировал подписку." - - keyboard = [ - [ - types.InlineKeyboardButton( - text="🎁 Выдать триал", - callback_data=f"admin_sub_grant_trial_{user_id}" - ), - types.InlineKeyboardButton( - text="💎 Выдать подписку", - callback_data=f"admin_sub_grant_{user_id}" - ) - ] - ] - - keyboard.append([ - types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}") - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - await callback.answer() + + if await _render_user_subscription_overview(callback, db, user_id): + await callback.answer() @admin_required @@ -559,11 +757,18 @@ async def process_user_search( async def show_user_management( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): user_id = int(callback.data.split('_')[-1]) + # Проверяем, откуда пришел пользователь + back_callback = "admin_users_list" + + # Если callback_data содержит информацию о том, что мы пришли из списка по балансу + # В реальности это сложно определить, поэтому будем использовать состояние + user_service = UserService() profile = await user_service.get_user_profile(db, user_id) @@ -616,13 +821,19 @@ async def show_user_management( else: text += "\nПодписка: Отсутствует" + # Проверяем состояние, чтобы определить, откуда пришел пользователь + current_state = await state.get_state() + if current_state == AdminStates.viewing_user_from_balance_list: + back_callback = "admin_users_balance_filter" + await callback.message.edit_text( text, - reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language) + reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language, back_callback) ) await callback.answer() + @admin_required @error_handler async def start_balance_edit( @@ -1450,68 +1661,9 @@ async def show_user_servers_management( db: AsyncSession ): user_id = int(callback.data.split('_')[-1]) - - user_service = UserService() - profile = await user_service.get_user_profile(db, user_id) - - if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) - return - - user = profile["user"] - subscription = profile["subscription"] - - text = f"🌍 Управление серверами пользователя\n\n" - text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" - - if subscription: - current_squads = subscription.connected_squads or [] - - if current_squads: - text += f"Текущие серверы ({len(current_squads)}):\n" - - for squad_uuid in current_squads: - try: - server = await get_server_squad_by_uuid(db, squad_uuid) - if server: - text += f"• {server.display_name}\n" - else: - text += f"• {squad_uuid[:8]}... (неизвестный)\n" - except Exception as e: - logger.error(f"Ошибка получения сервера {squad_uuid}: {e}") - text += f"• {squad_uuid[:8]}... (ошибка загрузки)\n" - else: - text += "Серверы: Не подключены\n" - - text += f"\nУстройства: {subscription.device_limit}\n" - traffic_display = f"{subscription.traffic_used_gb:.1f}/" - if subscription.traffic_limit_gb == 0: - traffic_display += "∞ ГБ" - else: - traffic_display += f"{subscription.traffic_limit_gb} ГБ" - text += f"Трафик: {traffic_display}\n" - else: - text += "❌ Подписка отсутствует" - - keyboard = [ - [ - types.InlineKeyboardButton(text="🌍 Сменить сервер", callback_data=f"admin_user_change_server_{user_id}"), - types.InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_user_devices_{user_id}") - ], - [ - types.InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_user_traffic_{user_id}"), - types.InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data=f"admin_user_reset_devices_{user_id}") - ], - [ - types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}") - ] - ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - await callback.answer() + + if await _render_user_subscription_overview(callback, db, user_id): + await callback.answer() @admin_required @@ -1547,7 +1699,7 @@ async def _show_servers_for_user( await callback.message.edit_text( "❌ Доступные серверы не найдены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] ]) ) return @@ -1590,8 +1742,8 @@ async def _show_servers_for_user( text += f"\n📝 Показано первых 20 из {len(servers_to_show)} серверов" keyboard.append([ - types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}") + types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") ]) await callback.message.edit_text( @@ -1682,7 +1834,7 @@ async def refresh_server_selection_screen( await callback.message.edit_text( "❌ Доступные серверы не найдены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] ]) ) return @@ -1706,8 +1858,8 @@ async def refresh_server_selection_screen( text += f"\n📝 Показано первых 15 из {len(servers)} серверов" keyboard.append([ - types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}") + types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") ]) await callback.message.edit_text( @@ -1747,7 +1899,7 @@ async def start_devices_edit( types.InlineKeyboardButton(text="10", callback_data=f"admin_user_devices_set_{user_id}_10") ], [ - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}") + types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}") ] ]) ) @@ -1773,14 +1925,14 @@ async def set_user_devices_button( await callback.message.edit_text( f"✅ Количество устройств изменено на: {devices}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) else: await callback.message.edit_text( "❌ Ошибка изменения количества устройств", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) @@ -1816,7 +1968,7 @@ async def process_devices_edit_text( await message.answer( f"✅ Количество устройств изменено на: {devices}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) else: @@ -1860,7 +2012,7 @@ async def start_traffic_edit( types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_user_traffic_set_{user_id}_0") ], [ - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}") + types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}") ] ]) ) @@ -1887,14 +2039,14 @@ async def set_user_traffic_button( await callback.message.edit_text( f"✅ Лимит трафика изменен на: {traffic_text}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) else: await callback.message.edit_text( "❌ Ошибка изменения лимита трафика", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) @@ -1931,7 +2083,7 @@ async def process_traffic_edit_text( await message.answer( f"✅ Лимит трафика изменен на: {traffic_text}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) else: @@ -1963,7 +2115,7 @@ async def confirm_reset_devices( "Продолжить?", reply_markup=get_confirmation_keyboard( f"admin_user_reset_devices_confirm_{user_id}", - f"admin_user_servers_{user_id}", + f"admin_user_subscription_{user_id}", db_user.language ) ) @@ -1993,7 +2145,7 @@ async def reset_user_devices( await callback.message.edit_text( "✅ Устройства пользователя успешно сброшены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) logger.info(f"Админ {db_user.id} сбросил устройства пользователя {user_id}") @@ -2001,7 +2153,7 @@ async def reset_user_devices( await callback.message.edit_text( "❌ Ошибка сброса устройств", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) @@ -2327,6 +2479,289 @@ async def change_subscription_type( ) await callback.answer() +@admin_required +@error_handler +async def admin_buy_subscription( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + user_id = int(callback.data.split('_')[-1]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + subscription = profile["subscription"] + + if not subscription: + await callback.answer("❌ У пользователя нет подписки", show_alert=True) + return + + available_periods = settings.get_available_subscription_periods() + + period_buttons = [] + for period in available_periods: + price_attr = f"PRICE_{period}_DAYS" + if hasattr(settings, price_attr): + price_kopeks = getattr(settings, price_attr) + price_rubles = price_kopeks // 100 + period_buttons.append([ + types.InlineKeyboardButton( + text=f"{period} дней ({price_rubles} ₽)", + callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}" + ) + ]) + + period_buttons.append([ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_user_subscription_{user_id}" + ) + ]) + + text = f"💳 Покупка подписки для пользователя\n\n" + text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += "Выберите период подписки:\n" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=period_buttons) + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_subscription_confirm( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + parts = callback.data.split('_') + user_id = int(parts[4]) + period_days = int(parts[5]) + price_kopeks = int(parts[6]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + subscription = profile["subscription"] + + if target_user.balance_kopeks < price_kopeks: + missing_kopeks = price_kopeks - target_user.balance_kopeks + await callback.message.edit_text( + f"❌ Недостаточно средств на балансе пользователя\n\n" + f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n" + f"💳 Стоимость подписки: {settings.format_price(price_kopeks)}\n" + f"📉 Не хватает: {settings.format_price(missing_kopeks)}\n\n" + f"Пополните баланс пользователя перед покупкой.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="⬅️ Назад к подписке", + callback_data=f"admin_user_subscription_{user_id}" + )] + ]) + ) + await callback.answer() + return + + price_rubles = price_kopeks // 100 + text = f"💳 Подтверждение покупки подписки\n\n" + text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + text += f"📅 Период подписки: {period_days} дней\n" + text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n" + text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += "Вы уверены, что хотите купить подписку для этого пользователя?" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✅ Подтвердить", + callback_data=f"admin_buy_sub_execute_{user_id}_{period_days}_{price_kopeks}" + ) + ], + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_sub_buy_{user_id}" + ) + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_subscription_execute( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + parts = callback.data.split('_') + user_id = int(parts[4]) + period_days = int(parts[5]) + price_kopeks = int(parts[6]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + subscription = profile["subscription"] + + if target_user.balance_kopeks < price_kopeks: + await callback.answer("❌ Недостаточно средств на балансе пользователя", show_alert=True) + return + + try: + from app.database.crud.user import subtract_user_balance + success = await subtract_user_balance( + db, target_user, price_kopeks, + f"Покупка подписки на {period_days} дней (администратор)" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + if subscription: + current_time = datetime.utcnow() + + if subscription.end_date <= current_time: + subscription.start_date = current_time + + subscription.end_date = current_time + timedelta(days=period_days) + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = current_time + + if subscription.is_trial or not subscription.is_active: + subscription.is_trial = False + if subscription.traffic_limit_gb != 0: + subscription.traffic_limit_gb = 0 + subscription.device_limit = settings.DEFAULT_DEVICE_LIMIT + if subscription.is_trial: + subscription.traffic_used_gb = 0.0 + + await db.commit() + await db.refresh(subscription) + + from app.database.crud.transaction import create_transaction + transaction = await create_transaction( + db=db, + user_id=target_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=price_kopeks, + description=f"Продление подписки на {period_days} дней (администратор)" + ) + + try: + from app.services.remnawave_service import RemnaWaveService + from app.external.remnawave_api import UserStatus, TrafficLimitStrategy + remnawave_service = RemnaWaveService() + + if target_user.remnawave_uuid: + async with remnawave_service.api as api: + remnawave_user = await api.update_user( + uuid=target_user.remnawave_uuid, + status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, + expire_at=subscription.end_date, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + hwid_device_limit=subscription.device_limit, + description=settings.format_remnawave_user_description( + full_name=target_user.full_name, + username=target_user.username, + telegram_id=target_user.telegram_id + ), + active_internal_squads=subscription.connected_squads + ) + else: + username = f"user_{target_user.telegram_id}" + async with remnawave_service.api as api: + remnawave_user = await api.create_user( + username=username, + expire_at=subscription.end_date, + status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + telegram_id=target_user.telegram_id, + hwid_device_limit=subscription.device_limit, + description=settings.format_remnawave_user_description( + full_name=target_user.full_name, + username=target_user.username, + telegram_id=target_user.telegram_id + ), + active_internal_squads=subscription.connected_squads + ) + + if remnawave_user and hasattr(remnawave_user, 'uuid'): + target_user.remnawave_uuid = remnawave_user.uuid + await db.commit() + + if remnawave_user: + logger.info(f"Пользователь {target_user.telegram_id} успешно обновлен в RemnaWave") + else: + logger.error(f"Ошибка обновления пользователя {target_user.telegram_id} в RemnaWave") + except Exception as e: + logger.error(f"Ошибка работы с RemnaWave для пользователя {target_user.telegram_id}: {e}") + + message = f"✅ Подписка пользователя продлена на {period_days} дней" + else: + message = "❌ Ошибка: у пользователя нет существующей подписки" + + await callback.message.edit_text( + f"{message}\n\n" + f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + f"💰 Списано: {settings.format_price(price_kopeks)}\n" + f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="⬅️ Назад к подписке", + callback_data=f"admin_user_subscription_{user_id}" + )] + ]) + ) + + try: + if callback.bot: + await callback.bot.send_message( + chat_id=target_user.telegram_id, + text=f"💳 Администратор продлил вашу подписку\n\n" + f"📅 Подписка продлена на {period_days} дней\n" + f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" + f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления пользователю {target_user.telegram_id}: {e}") + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка покупки подписки администратором: {e}") + await callback.answer("❌ Ошибка при покупке подписки", show_alert=True) + + await db.rollback() + @admin_required @error_handler @@ -2468,6 +2903,11 @@ def register_handlers(dp: Dispatcher): F.data.startswith("admin_users_list_page_") ) + dp.callback_query.register( + handle_users_balance_list_pagination, + F.data.startswith("admin_users_balance_list_page_") + ) + dp.callback_query.register( start_user_search, F.data == "admin_users_search" @@ -2633,3 +3073,39 @@ def register_handlers(dp: Dispatcher): change_subscription_type_confirm, F.data.startswith("admin_sub_type_") ) + + # Регистрация обработчика покупки подписки администратором + dp.callback_query.register( + admin_buy_subscription, + F.data.startswith("admin_sub_buy_") + ) + + # Регистрация дополнительных обработчиков для покупки подписки + dp.callback_query.register( + admin_buy_subscription_confirm, + F.data.startswith("admin_buy_sub_confirm_") + ) + + dp.callback_query.register( + admin_buy_subscription_execute, + F.data.startswith("admin_buy_sub_execute_") + ) + + # Регистрация обработчиков для фильтрации пользователей + dp.callback_query.register( + show_users_filters, + F.data == "admin_users_filters" + ) + + dp.callback_query.register( + show_users_list_by_balance, + F.data == "admin_users_balance_filter" + ) + + dp.callback_query.register( + show_users_list_by_balance, + F.data.startswith("admin_users_balance_list_page_") + ) + + + diff --git a/app/handlers/balance.py b/app/handlers/balance.py index 321cf259..870b9649 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -26,30 +26,22 @@ TRANSACTIONS_PER_PAGE = 10 def get_quick_amount_buttons(language: str) -> list: - """ - Генерирует кнопки быстрого выбора суммы пополнения на основе - AVAILABLE_SUBSCRIPTION_PERIODS и PRICE_*_DAYS - """ if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: return [] buttons = [] periods = settings.get_available_subscription_periods() - # Ограничиваем до 6 кнопок (3 ряда по 2 кнопки) periods = periods[:6] for period in periods: - # Получаем цену из настроек price_attr = f"PRICE_{period}_DAYS" if hasattr(settings, price_attr): price_kopeks = getattr(settings, price_attr) price_rubles = price_kopeks // 100 - # Создаем callback_data для каждой кнопки callback_data = f"quick_amount_{price_kopeks}" - # Добавляем кнопку buttons.append( types.InlineKeyboardButton( text=f"{price_rubles} ₽ ({period} дней)", @@ -57,7 +49,6 @@ def get_quick_amount_buttons(language: str) -> list: ) ) - # Разбиваем кнопки на ряды (по 2 в ряд) keyboard_rows = [] for i in range(0, len(buttons), 2): keyboard_rows.append(buttons[i:i + 2]) @@ -388,7 +379,67 @@ async def start_tribute_payment( await callback.answer("❌ Ошибка создания платежа", show_alert=True) await callback.answer() - + +async def handle_successful_topup_with_cart( + user_id: int, + amount_kopeks: int, + bot, + db: AsyncSession +): + from app.database.crud.user import get_user_by_id + from aiogram.fsm.context import FSMContext + from aiogram.fsm.storage.base import StorageKey + from app.bot import dp + + user = await get_user_by_id(db, user_id) + if not user: + return + + storage = dp.storage + key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id) + + try: + state_data = await storage.get_data(key) + current_state = await storage.get_state(key) + + if (current_state == "SubscriptionStates:cart_saved_for_topup" and + state_data.get('saved_cart')): + + texts = get_texts(user.language) + total_price = state_data.get('total_price', 0) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="🛒 Вернуться к оформлению подписки", + callback_data="return_to_saved_cart" + )], + [types.InlineKeyboardButton( + text="💰 Мой баланс", + callback_data="menu_balance" + )], + [types.InlineKeyboardButton( + text="🏠 Главное меню", + callback_data="back_to_menu" + )] + ]) + + success_text = ( + f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n" + f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n" + f"🛒 У вас есть сохраненная корзина подписки\n" + f"Стоимость: {texts.format_price(total_price)}\n\n" + f"Хотите продолжить оформление?" + ) + + await bot.send_message( + chat_id=user.telegram_id, + text=success_text, + reply_markup=keyboard, + parse_mode="HTML" + ) + + except Exception as e: + logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}") @error_handler async def request_support_topup( @@ -401,7 +452,7 @@ async def request_support_topup( 🛠️ Пополнение через поддержку Для пополнения баланса обратитесь в техподдержку: -{settings.SUPPORT_USERNAME} +{settings.get_support_contact_display_html()} Укажите: • ID: {db_user.telegram_id} @@ -418,8 +469,8 @@ async def request_support_topup( keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( - text="💬 Написать в поддержку", - url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}" + text="💬 Написать в поддержку", + url=settings.get_support_contact_url() or "https://t.me/" )], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) @@ -608,7 +659,7 @@ async def process_yookassa_payment_amount( f"4. Деньги поступят на баланс автоматически\n\n" f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}", + f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", reply_markup=keyboard, parse_mode="HTML" ) @@ -693,7 +744,7 @@ async def process_yookassa_sbp_payment_amount( f"4. Деньги поступят на баланс автоматически\n\n" f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем СБП от всех банков-участников\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}", + f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", reply_markup=keyboard, parse_mode="HTML" ) @@ -754,7 +805,9 @@ async def check_yookassa_payment_status( elif payment.is_pending: message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше." elif payment.is_failed: - message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}" + message_text += ( + f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}" + ) await callback.answer(message_text, show_alert=True) @@ -892,7 +945,7 @@ async def process_cryptobot_payment_amount( f"4. Деньги поступят на баланс автоматически\n\n" f"🔒 Оплата проходит через защищенную систему CryptoBot\n" f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}", + f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", reply_markup=keyboard, parse_mode="HTML" ) @@ -948,7 +1001,9 @@ async def check_cryptobot_payment_status( elif payment.is_pending: message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше." elif payment.is_expired: - message_text += f"\n❌ Платеж истек. Обратитесь в {settings.SUPPORT_USERNAME}" + message_text += ( + f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}" + ) await callback.answer(message_text, show_alert=True) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 81d495f9..d14ccfd0 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -11,29 +11,36 @@ from app.localization.texts import get_texts, get_rules_sync from app.database.models import User from app.utils.user_utils import mark_user_as_had_paid_subscription from app.database.crud.user_message import get_random_active_message +from app.services.subscription_checkout_service import ( + has_subscription_checkout_draft, + should_offer_checkout_resume, +) logger = logging.getLogger(__name__) async def show_main_menu( - callback: types.CallbackQuery, - db_user: User, + callback: types.CallbackQuery, + db_user: User, db: AsyncSession ): texts = get_texts(db_user.language) - + from datetime import datetime db_user.last_activity = datetime.utcnow() await db.commit() - + has_active_subscription = bool(db_user.subscription) subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -44,11 +51,13 @@ async def show_main_menu( subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() + async def mark_user_as_had_paid_subscription( db: AsyncSession, user: User @@ -89,17 +98,20 @@ async def handle_back_to_menu( db: AsyncSession ): await state.clear() - + texts = get_texts(db_user.language) - + has_active_subscription = db_user.subscription is not None subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -109,13 +121,13 @@ async def handle_back_to_menu( has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription + subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() - def _get_subscription_status(user: User, texts) -> str: if not user.subscription: return texts.t("SUB_STATUS_NONE", "❌ Отсутствует") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 6d1e84e7..4e1714cb 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,6 +1,5 @@ import logging from aiogram import Dispatcher, types, F -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User @@ -114,41 +113,7 @@ async def handle_successful_payment( if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [ - InlineKeyboardButton( - text=texts.MY_BALANCE_BUTTON, - callback_data="menu_balance", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("MAIN_MENU_BUTTON", "🏠 Главное меню"), - callback_data="back_to_menu", - ) - ], - ] - ) + keyboard = await payment_service.build_topup_success_keyboard(user) transaction_id_short = payment.telegram_payment_charge_id[:8] diff --git a/app/handlers/start.py b/app/handlers/start.py index 7c44586d..a33a4bcb 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import RegistrationStates from app.database.crud.user import ( - get_user_by_telegram_id, create_user, get_user_by_referral_code + get_user_by_telegram_id, + create_user, + get_user_by_referral_code, +) +from app.database.crud.campaign import ( + get_campaign_by_start_parameter, + get_campaign_by_id, ) from app.database.models import UserStatus from app.keyboards.inline import ( @@ -18,15 +24,52 @@ from app.keyboards.inline import ( from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts from app.services.referral_service import process_referral_registration +from app.services.campaign_service import AdvertisingCampaignService from app.utils.user_utils import generate_unique_referral_code from app.database.crud.user_message import get_random_active_message -from aiogram.enums import ChatMemberStatus -from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest logger = logging.getLogger(__name__) +async def _apply_campaign_bonus_if_needed( + db: AsyncSession, + user, + state_data: dict, + texts, +): + campaign_id = state_data.get("campaign_id") if state_data else None + if not campaign_id: + return None + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign or not campaign.is_active: + return None + + service = AdvertisingCampaignService() + result = await service.apply_campaign_bonus(db, user, campaign) + if not result.success: + return None + + if result.bonus_type == "balance": + amount_text = texts.format_price(result.balance_kopeks) + return texts.CAMPAIGN_BONUS_BALANCE.format( + amount=amount_text, + name=campaign.name, + ) + + if result.bonus_type == "subscription": + traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0) + return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format( + name=campaign.name, + days=result.subscription_days, + traffic=traffic_text, + devices=result.subscription_device_limit, + ) + + return None + + async def handle_potential_referral_code( message: types.Message, state: FSMContext, @@ -95,13 +138,29 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, logger.info(f"🚀 START: Обработка /start от {message.from_user.id}") referral_code = None - if len(message.text.split()) > 1: - potential_code = message.text.split()[1] - referral_code = potential_code - logger.info(f"🔎 Найден реферальный код: {referral_code}") - + campaign = None + start_args = message.text.split() + if len(start_args) > 1: + start_parameter = start_args[1] + campaign = await get_campaign_by_start_parameter( + db, + start_parameter, + only_active=True, + ) + + if campaign: + logger.info( + "📣 Найдена рекламная кампания %s (start=%s)", + campaign.id, + campaign.start_parameter, + ) + await state.update_data(campaign_id=campaign.id) + else: + referral_code = start_parameter + logger.info(f"🔎 Найден реферальный код: {referral_code}") + if referral_code: - await state.set_data({'referral_code': referral_code}) + await state.update_data(referral_code=referral_code) user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id) @@ -139,7 +198,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, await db.commit() texts = get_texts(user.language) - + if referral_code and not user.referred_by_id: await message.answer( texts.t( @@ -147,6 +206,19 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", ) ) + + if campaign: + try: + await message.answer( + texts.t( + "CAMPAIGN_EXISTING_USERL", + "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", + ) + ) + except Exception as e: + logger.error( + f"Ошибка отправки уведомления о рекламной кампании: {e}" + ) has_active_subscription = user.subscription is not None subscription_is_active = False @@ -583,9 +655,35 @@ async def complete_registration_from_callback( logger.info(f"✅ Реферальная регистрация обработана для {user.id}") except Exception as e: logger.error(f"Ошибка при обработке реферальной регистрации: {e}") - + + campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) + + try: + await db.refresh(user) + except Exception as refresh_error: + logger.error( + "Ошибка обновления данных пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_error, + ) + + try: + await db.refresh(user, ["subscription"]) + except Exception as refresh_subscription_error: + logger.error( + "Ошибка обновления подписки пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_subscription_error, + ) + await state.clear() + if campaign_message: + try: + await callback.message.answer(campaign_message) + except Exception as e: + logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, callback.from_user) @@ -601,10 +699,10 @@ async def complete_registration_from_callback( else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - has_active_subscription = user.subscription is not None + has_active_subscription = bool(getattr(user, "subscription", None)) subscription_is_active = False - - if user.subscription: + + if getattr(user, "subscription", None): subscription_is_active = user.subscription.is_active menu_text = await get_main_menu_text(user, texts, db) @@ -763,9 +861,35 @@ async def complete_registration( logger.info(f"✅ Реферальная регистрация обработана для {user.id}") except Exception as e: logger.error(f"Ошибка при обработке реферальной регистрации: {e}") - + + campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) + + try: + await db.refresh(user) + except Exception as refresh_error: + logger.error( + "Ошибка обновления данных пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_error, + ) + + try: + await db.refresh(user, ["subscription"]) + except Exception as refresh_subscription_error: + logger.error( + "Ошибка обновления подписки пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_subscription_error, + ) + await state.clear() + if campaign_message: + try: + await message.answer(campaign_message) + except Exception as e: + logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, message.from_user) @@ -781,10 +905,10 @@ async def complete_registration( else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - has_active_subscription = user.subscription is not None + has_active_subscription = bool(getattr(user, "subscription", None)) subscription_is_active = False - - if user.subscription: + + if getattr(user, "subscription", None): subscription_is_active = user.subscription.is_active menu_text = await get_main_menu_text(user, texts, db) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index b4f7ed41..798cf4df 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -37,12 +37,21 @@ from app.keyboards.inline import ( get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, get_devices_management_keyboard, get_device_reset_confirm_keyboard, - get_device_management_help_keyboard + get_device_management_help_keyboard, + get_payment_methods_keyboard_with_cart, + get_subscription_confirm_keyboard_with_cart, + get_insufficient_balance_keyboard_with_cart ) from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService from app.services.admin_notification_service import AdminNotificationService from app.services.subscription_service import SubscriptionService +from app.services.subscription_checkout_service import ( + clear_subscription_checkout_draft, + get_subscription_checkout_draft, + save_subscription_checkout_draft, + should_offer_checkout_resume, +) from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, @@ -56,6 +65,109 @@ logger = logging.getLogger(__name__) TRAFFIC_PRICES = get_traffic_prices() + +async def _prepare_subscription_summary( + db_user: User, + data: Dict[str, Any], + texts, +) -> Tuple[str, Dict[str, Any]]: + from app.utils.pricing_utils import ( + calculate_months_from_days, + format_period_description, + validate_pricing_calculation, + ) + + summary_data = dict(data) + countries = await _get_available_countries() + + months_in_period = calculate_months_from_days(summary_data['period_days']) + period_display = format_period_description(summary_data['period_days'], db_user.language) + + base_price = PERIOD_PRICES[summary_data['period_days']] + + if settings.is_traffic_fixed(): + traffic_limit = settings.get_fixed_traffic_limit() + traffic_price_per_month = settings.get_traffic_price(traffic_limit) + final_traffic_gb = traffic_limit + else: + traffic_gb = summary_data.get('traffic_gb', 0) + traffic_price_per_month = settings.get_traffic_price(traffic_gb) + final_traffic_gb = traffic_gb + + total_traffic_price = traffic_price_per_month * months_in_period + + countries_price_per_month = 0 + selected_countries_names: List[str] = [] + selected_server_prices: List[int] = [] + + selected_country_ids = set(summary_data.get('countries', [])) + for country in countries: + if country['uuid'] in selected_country_ids: + server_price_per_month = country['price_kopeks'] + countries_price_per_month += server_price_per_month + selected_countries_names.append(country['name']) + selected_server_prices.append(server_price_per_month * months_in_period) + + total_countries_price = countries_price_per_month * months_in_period + + devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) + devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE + total_devices_price = devices_price_per_month * months_in_period + + total_price = base_price + total_traffic_price + total_countries_price + total_devices_price + + monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month + is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) + + if not is_valid: + raise ValueError("Subscription price calculation validation failed") + + summary_data['total_price'] = total_price + summary_data['server_prices_for_period'] = selected_server_prices + + if settings.is_traffic_fixed(): + if final_traffic_gb == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{final_traffic_gb} ГБ" + else: + if summary_data.get('traffic_gb', 0) == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ" + + details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] + + if total_traffic_price > 0: + details_lines.append( + f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" + ) + if total_countries_price > 0: + details_lines.append( + f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" + ) + if total_devices_price > 0: + details_lines.append( + f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" + ) + + details_text = "\n".join(details_lines) + + summary_text = ( + "📋 Сводка заказа\n\n" + f"📅 Период: {period_display}\n" + f"📊 Трафик: {traffic_display}\n" + f"🌍 Страны: {', '.join(selected_countries_names)}\n" + f"📱 Устройства: {devices_selected}\n\n" + "💰 Детализация стоимости:\n" + f"{details_text}\n\n" + f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + "Подтверждаете покупку?" + ) + + return summary_text, summary_data + async def show_subscription_info( callback: types.CallbackQuery, db_user: User, @@ -574,7 +686,97 @@ async def start_subscription_purchase( await state.set_state(SubscriptionStates.selecting_period) await callback.answer() +async def save_cart_and_redirect_to_topup( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + missing_amount: int +): + from app.handlers.balance import show_payment_methods + + texts = get_texts(db_user.language) + data = await state.get_data() + + await state.set_state(SubscriptionStates.cart_saved_for_topup) + await state.update_data({ + **data, + 'saved_cart': True, + 'missing_amount': missing_amount, + 'return_to_cart': True + }) + + await callback.message.edit_text( + f"💰 Недостаточно средств для оформления подписки\n\n" + f"Требуется: {texts.format_price(missing_amount)}\n" + f"У вас: {texts.format_price(db_user.balance_kopeks)}\n\n" + f"🛒 Ваша корзина сохранена!\n" + f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n" + f"Выберите способ пополнения:", + reply_markup=get_payment_methods_keyboard_with_cart(db_user.language), + parse_mode="HTML" + ) +async def return_to_saved_cart( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + data = await state.get_data() + texts = get_texts(db_user.language) + + if not data.get('saved_cart'): + await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True) + return + + total_price = data.get('total_price', 0) + + if db_user.balance_kopeks < total_price: + missing_amount = total_price - db_user.balance_kopeks + await callback.message.edit_text( + f"❌ Все еще недостаточно средств\n\n" + f"Требуется: {texts.format_price(total_price)}\n" + f"У вас: {texts.format_price(db_user.balance_kopeks)}\n" + f"Не хватает: {texts.format_price(missing_amount)}", + reply_markup=get_insufficient_balance_keyboard_with_cart(db_user.language) + ) + return + + from app.utils.pricing_utils import calculate_months_from_days, format_period_description + + countries = await _get_available_countries() + selected_countries_names = [] + + months_in_period = calculate_months_from_days(data['period_days']) + period_display = format_period_description(data['period_days'], db_user.language) + + for country in countries: + if country['uuid'] in data['countries']: + selected_countries_names.append(country['name']) + + if settings.is_traffic_fixed(): + traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ" + else: + traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ" + + summary_text = ( + "🛒 Восстановленная корзина\n\n" + f"📅 Период: {period_display}\n" + f"📊 Трафик: {traffic_display}\n" + f"🌍 Страны: {', '.join(selected_countries_names)}\n" + f"📱 Устройства: {data['devices']}\n\n" + f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + "Подтверждаете покупку?" + ) + + await callback.message.edit_text( + summary_text, + reply_markup=get_subscription_confirm_keyboard_with_cart(db_user.language), + parse_mode="HTML" + ) + + await state.set_state(SubscriptionStates.confirming_purchase) + await callback.answer("✅ Корзина восстановлена!") async def handle_add_countries( callback: types.CallbackQuery, @@ -721,6 +923,13 @@ async def apply_countries_changes( data = await state.get_data() texts = get_texts(db_user.language) + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) subscription = db_user.subscription selected_countries = data.get('countries', []) @@ -1512,7 +1721,10 @@ async def confirm_add_devices( missing_kopeks = price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2105,107 +2317,29 @@ async def devices_continue( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import calculate_months_from_days, format_period_description, validate_pricing_calculation - if not callback.data == "devices_continue": await callback.answer("⚠️ Некорректный запрос", show_alert=True) return - + data = await state.get_data() texts = get_texts(db_user.language) - - countries = await _get_available_countries() - selected_countries_names = [] - - months_in_period = calculate_months_from_days(data['period_days']) - period_display = format_period_description(data['period_days'], db_user.language) - - base_price = PERIOD_PRICES[data['period_days']] - - if settings.is_traffic_fixed(): - traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit()) - final_traffic_gb = settings.get_fixed_traffic_limit() - else: - traffic_price_per_month = settings.get_traffic_price(data['traffic_gb']) - final_traffic_gb = data['traffic_gb'] - - total_traffic_price = traffic_price_per_month * months_in_period - - countries_price_per_month = 0 - selected_server_prices = [] - - for country in countries: - if country['uuid'] in data['countries']: - server_price_per_month = country['price_kopeks'] - countries_price_per_month += server_price_per_month - selected_countries_names.append(country['name']) - selected_server_prices.append(server_price_per_month * months_in_period) - - total_countries_price = countries_price_per_month * months_in_period - - additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - - total_price = base_price + total_traffic_price + total_countries_price + total_devices_price - - monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month - is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) - - if not is_valid: + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) + except ValueError: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) return - - data['total_price'] = total_price - data['server_prices_for_period'] = selected_server_prices - await state.set_data(data) - - if settings.is_traffic_fixed(): - if final_traffic_gb == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{final_traffic_gb} ГБ" - else: - if data['traffic_gb'] == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{data['traffic_gb']} ГБ" - - details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] - if total_traffic_price > 0: - details_lines.append( - f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" - ) - if total_countries_price > 0: - details_lines.append( - f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" - ) - if total_devices_price > 0: - details_lines.append( - f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" - ) - details_text = "\n".join(details_lines) + await state.set_data(prepared_data) + await save_subscription_checkout_draft(db_user.id, prepared_data) - summary_text = ( - "📋 Сводка заказа\n\n" - f"📅 Период: {period_display}\n" - f"📊 Трафик: {traffic_display}\n" - f"🌍 Страны: {', '.join(selected_countries_names)}\n" - f"📱 Устройства: {data['devices']}\n\n" - "💰 Детализация стоимости:\n" - f"{details_text}\n\n" - f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" - "Подтверждаете покупку?" - ) - await callback.message.edit_text( summary_text, reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML" + parse_mode="HTML", ) - + await state.set_state(SubscriptionStates.confirming_purchase) await callback.answer() @@ -2221,7 +2355,14 @@ async def confirm_purchase( data = await state.get_data() texts = get_texts(db_user.language) - + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) + countries = await _get_available_countries() months_in_period = calculate_months_from_days(data['period_days']) @@ -2276,11 +2417,16 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return + purchase_completed = False + try: success = await subtract_user_balance( db, db_user, final_price, @@ -2291,7 +2437,10 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2468,6 +2617,7 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + purchase_completed = True logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}₽") except Exception as e: @@ -2477,9 +2627,48 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + if purchase_completed: + await clear_subscription_checkout_draft(db_user.id) + await state.clear() await callback.answer() + + +async def resume_subscription_checkout( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, +): + texts = get_texts(db_user.language) + + draft = await get_subscription_checkout_draft(db_user.id) + + if not draft: + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts) + except ValueError as exc: + logger.error( + f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}" + ) + await clear_subscription_checkout_draft(db_user.id) + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + await state.set_data(prepared_data) + await state.set_state(SubscriptionStates.confirming_purchase) + await save_subscription_checkout_draft(db_user.id, prepared_data) + + await callback.message.edit_text( + summary_text, + reply_markup=get_subscription_confirm_keyboard(db_user.language), + parse_mode="HTML", + ) + + await callback.answer() async def add_traffic( callback: types.CallbackQuery, db_user: User, @@ -2777,14 +2966,15 @@ async def handle_subscription_cancel( db_user: User, db: AsyncSession ): - + texts = get_texts(db_user.language) - + await state.clear() - + await clear_subscription_checkout_draft(db_user.id) + from app.handlers.menu import show_main_menu await show_main_menu(callback, db_user, db) - + await callback.answer("❌ Покупка отменена") async def _get_available_countries(): @@ -3652,6 +3842,19 @@ async def confirm_switch_traffic( await callback.answer() +async def clear_saved_cart( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + await state.clear() + + from app.handlers.menu import show_main_menu + await show_main_menu(callback, db_user, db) + + await callback.answer("🗑️ Корзина очищена") + async def execute_switch_traffic( callback: types.CallbackQuery, diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 067c1e83..d0ecf018 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -36,12 +36,15 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"), InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics") ], + [ + InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns") + ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") ] @@ -107,12 +110,26 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"), InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive") ], + [ + InlineKeyboardButton(text="⚙️ Фильтры", callback_data="admin_users_filters") + ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users") ] ]) +def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="💰 По балансу", callback_data="admin_users_balance_filter") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") + ] + ]) + + def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -147,6 +164,142 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) +def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"), + InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create") + ], + [ + InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") + ] + ]) + + +def get_campaign_management_keyboard( + campaign_id: int, is_active: bool, language: str = "ru" +) -> InlineKeyboardMarkup: + status_text = "🔴 Выключить" if is_active else "🟢 Включить" + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="📊 Статистика", + callback_data=f"admin_campaign_stats_{campaign_id}", + ), + InlineKeyboardButton( + text=status_text, + callback_data=f"admin_campaign_toggle_{campaign_id}", + ), + ], + [ + InlineKeyboardButton( + text="✏️ Редактировать", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ], + [ + InlineKeyboardButton( + text="🗑️ Удалить", + callback_data=f"admin_campaign_delete_{campaign_id}", + ) + ], + [ + InlineKeyboardButton( + text="⬅️ К списку", callback_data="admin_campaigns_list" + ) + ], + ] + ) + + +def get_campaign_edit_keyboard( + campaign_id: int, + *, + is_balance_bonus: bool, + language: str = "ru", +) -> InlineKeyboardMarkup: + texts = get_texts(language) + + keyboard: List[List[InlineKeyboardButton]] = [ + [ + InlineKeyboardButton( + text="✏️ Название", + callback_data=f"admin_campaign_edit_name_{campaign_id}", + ), + InlineKeyboardButton( + text="🔗 Параметр", + callback_data=f"admin_campaign_edit_start_{campaign_id}", + ), + ] + ] + + if is_balance_bonus: + keyboard.append( + [ + InlineKeyboardButton( + text="💰 Бонус на баланс", + callback_data=f"admin_campaign_edit_balance_{campaign_id}", + ) + ] + ) + else: + keyboard.extend( + [ + [ + InlineKeyboardButton( + text="📅 Длительность", + callback_data=f"admin_campaign_edit_sub_days_{campaign_id}", + ), + InlineKeyboardButton( + text="🌐 Трафик", + callback_data=f"admin_campaign_edit_sub_traffic_{campaign_id}", + ), + ], + [ + InlineKeyboardButton( + text="📱 Устройства", + callback_data=f"admin_campaign_edit_sub_devices_{campaign_id}", + ), + InlineKeyboardButton( + text="🌍 Серверы", + callback_data=f"admin_campaign_edit_sub_servers_{campaign_id}", + ), + ], + ] + ) + + keyboard.append( + [ + InlineKeyboardButton( + text=texts.BACK, callback_data=f"admin_campaign_manage_{campaign_id}" + ) + ] + ) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"), + InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns") + ] + ]) + + def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -236,14 +389,13 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup: +def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru", back_callback: str = "admin_users_list") -> InlineKeyboardMarkup: keyboard = [ [ InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"), - InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}") + InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}") ], [ - InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"), InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}") ], [ @@ -267,7 +419,7 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str = ]) keyboard.append([ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list") + InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback) ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index e116b1f7..e6306396 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -65,7 +65,8 @@ def get_main_menu_keyboard( has_active_subscription: bool = False, subscription_is_active: bool = False, balance_kopeks: int = 0, - subscription=None + subscription=None, + show_resume_checkout: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -143,7 +144,15 @@ def get_main_menu_keyboard( keyboard.append(subscription_buttons) else: keyboard.append([subscription_buttons[0]]) - + + if show_resume_checkout: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) + keyboard.extend([ [ InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"), @@ -178,17 +187,32 @@ def get_back_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: ]) -def get_insufficient_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: +def get_insufficient_balance_keyboard( + language: str = DEFAULT_LANGUAGE, + resume_callback: str | None = None, + ) -> InlineKeyboardMarkup: + texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ + keyboard: list[list[InlineKeyboardButton]] = [ [ InlineKeyboardButton( text=texts.GO_TO_BALANCE_TOP_UP, callback_data="balance_topup", ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")], - ]) + ] + ] + + if resume_callback: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data=resume_callback, + ) + ]) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_subscription_keyboard( @@ -263,8 +287,52 @@ def get_subscription_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) +def get_payment_methods_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: + keyboard = get_payment_methods_keyboard(0, language) + + # Добавляем кнопку "Очистить корзину" + keyboard.inline_keyboard.append([ + InlineKeyboardButton( + text="🗑️ Очистить корзину и вернуться", + callback_data="clear_saved_cart" + ) + ]) + + return keyboard -def get_trial_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: +def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="✅ Подтвердить покупку", + callback_data="subscription_confirm" + )], + [InlineKeyboardButton( + text="🗑️ Очистить корзину", + callback_data="clear_saved_cart" + )], + [InlineKeyboardButton( + text="🔙 Назад", + callback_data="back_to_menu" + )] + ]) + +def get_insufficient_balance_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="💰 Пополнить баланс", + callback_data="balance_topup" + )], + [InlineKeyboardButton( + text="🗑️ Очистить корзину", + callback_data="clear_saved_cart" + )], + [InlineKeyboardButton( + text="🔙 Назад", + callback_data="back_to_menu" + )] + ]) + +def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -652,7 +720,10 @@ def get_support_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text=texts.CONTACT_SUPPORT, url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}") + InlineKeyboardButton( + text=texts.CONTACT_SUPPORT, + url=settings.get_support_contact_url() or "https://t.me/" + ) ], [ InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py new file mode 100644 index 00000000..8fecc497 --- /dev/null +++ b/app/services/campaign_service.py @@ -0,0 +1,171 @@ +import logging +from dataclasses import dataclass +from typing import List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.campaign import record_campaign_registration +from app.database.crud.subscription import ( + create_paid_subscription, + get_subscription_by_user_id, +) +from app.database.crud.user import add_user_balance +from app.database.models import AdvertisingCampaign, User +from app.services.subscription_service import SubscriptionService + +logger = logging.getLogger(__name__) + + +@dataclass +class CampaignBonusResult: + success: bool + bonus_type: Optional[str] = None + balance_kopeks: int = 0 + subscription_days: Optional[int] = None + subscription_traffic_gb: Optional[int] = None + subscription_device_limit: Optional[int] = None + subscription_squads: Optional[List[str]] = None + + +class AdvertisingCampaignService: + def __init__(self) -> None: + self.subscription_service = SubscriptionService() + + async def apply_campaign_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + if not campaign.is_active: + logger.warning( + "⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id + ) + return CampaignBonusResult(success=False) + + if campaign.is_balance_bonus: + return await self._apply_balance_bonus(db, user, campaign) + + if campaign.is_subscription_bonus: + return await self._apply_subscription_bonus(db, user, campaign) + + logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type) + return CampaignBonusResult(success=False) + + async def _apply_balance_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + amount = campaign.balance_bonus_kopeks or 0 + if amount <= 0: + logger.info("ℹ️ Кампания %s не имеет бонуса на баланс", campaign.id) + return CampaignBonusResult(success=False) + + description = f"Бонус за регистрацию по кампании '{campaign.name}'" + success = await add_user_balance( + db, + user, + amount, + description=description, + ) + + if not success: + return CampaignBonusResult(success=False) + + await record_campaign_registration( + db, + campaign_id=campaign.id, + user_id=user.id, + bonus_type="balance", + balance_bonus_kopeks=amount, + ) + + logger.info( + "💰 Пользователю %s начислен бонус %s₽ по кампании %s", + user.telegram_id, + amount / 100, + campaign.id, + ) + + return CampaignBonusResult( + success=True, + bonus_type="balance", + balance_kopeks=amount, + ) + + async def _apply_subscription_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + existing_subscription = await get_subscription_by_user_id(db, user.id) + if existing_subscription: + logger.warning( + "⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен", + user.telegram_id, + campaign.id, + ) + return CampaignBonusResult(success=False) + + duration_days = campaign.subscription_duration_days or 0 + if duration_days <= 0: + logger.info( + "ℹ️ Кампания %s не содержит корректной длительности подписки", + campaign.id, + ) + return CampaignBonusResult(success=False) + + traffic_limit = campaign.subscription_traffic_gb + device_limit = ( + campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT + ) + squads = list(campaign.subscription_squads or []) + + if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None): + squads = [settings.TRIAL_SQUAD_UUID] + + new_subscription = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=duration_days, + traffic_limit_gb=traffic_limit or 0, + device_limit=device_limit, + connected_squads=squads, + ) + + try: + await self.subscription_service.create_remnawave_user(db, new_subscription) + except Exception as error: + logger.error( + "❌ Ошибка синхронизации RemnaWave для кампании %s: %s", + campaign.id, + error, + ) + + await record_campaign_registration( + db, + campaign_id=campaign.id, + user_id=user.id, + bonus_type="subscription", + subscription_duration_days=duration_days, + ) + + logger.info( + "🎁 Пользователю %s выдана подписка по кампании %s на %s дней", + user.telegram_id, + campaign.id, + duration_days, + ) + + return CampaignBonusResult( + success=True, + bonus_type="subscription", + subscription_days=duration_days, + subscription_traffic_gb=traffic_limit or 0, + subscription_device_limit=device_limit, + subscription_squads=squads, + ) diff --git a/app/services/payment_service.py b/app/services/payment_service.py index dc980dfc..0855be9e 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -22,6 +22,10 @@ from app.external.cryptobot import CryptoBotService from app.utils.currency_converter import currency_converter from app.database.database import get_db from app.localization.texts import get_texts +from app.services.subscription_checkout_service import ( + has_subscription_checkout_draft, + should_offer_checkout_resume, +) logger = logging.getLogger(__name__) @@ -33,7 +37,49 @@ class PaymentService: self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None self.stars_service = TelegramStarsService(bot) if bot else None self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None - + + async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup: + texts = get_texts(user.language if user else "ru") + + has_active_subscription = ( + user + and user.subscription + and not user.subscription.is_trial + and user.subscription.is_active + ) + + first_button = InlineKeyboardButton( + text=( + texts.MENU_EXTEND_SUBSCRIPTION + if has_active_subscription + else texts.MENU_BUY_SUBSCRIPTION + ), + callback_data=( + "subscription_extend" if has_active_subscription else "menu_buy" + ), + ) + + keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]] + + if user: + draft_exists = await has_subscription_checkout_draft(user.id) + if should_offer_checkout_resume(user, draft_exists): + keyboard_rows.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) + + keyboard_rows.append([ + InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance") + ]) + keyboard_rows.append([ + InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + async def create_stars_invoice( self, amount_kopeks: int, @@ -124,33 +170,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, @@ -432,33 +452,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, @@ -545,33 +539,7 @@ class PaymentService: user = await get_user_by_telegram_id(db, telegram_id) break - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) message = ( f"✅ Платеж успешно завершен!\n\n" @@ -827,33 +795,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, diff --git a/app/services/subscription_checkout_service.py b/app/services/subscription_checkout_service.py new file mode 100644 index 00000000..e49b6a5e --- /dev/null +++ b/app/services/subscription_checkout_service.py @@ -0,0 +1,52 @@ +from typing import Optional + +from app.database.models import User +from app.utils.cache import UserCache + + +_CHECKOUT_SESSION_KEY = "subscription_checkout" +_CHECKOUT_TTL_SECONDS = 3600 + + +async def save_subscription_checkout_draft( + user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS +) -> bool: + """Persist subscription checkout draft data in cache.""" + + return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl) + + +async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]: + """Retrieve subscription checkout draft from cache.""" + + return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def clear_subscription_checkout_draft(user_id: int) -> bool: + """Remove stored subscription checkout draft for the user.""" + + return await UserCache.delete_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def has_subscription_checkout_draft(user_id: int) -> bool: + draft = await get_subscription_checkout_draft(user_id) + return draft is not None + + +def should_offer_checkout_resume(user: User, has_draft: bool) -> bool: + """ + Determine whether checkout resume button should be available for the user. + + Only users without an active paid subscription or users currently on trial + are eligible to continue assembling the subscription from the saved draft. + """ + + if not has_draft: + return False + + subscription = getattr(user, "subscription", None) + + if subscription is None: + return True + + return bool(getattr(subscription, "is_trial", False)) diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 9daf0b0f..6d5de2eb 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -5,8 +5,6 @@ from datetime import datetime from aiogram import Bot from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from sqlalchemy.ext.asyncio import AsyncSession - from app.config import settings from app.database.database import get_db from app.database.models import Transaction, TransactionType, PaymentMethod @@ -15,7 +13,7 @@ from app.database.crud.transaction import ( ) from app.database.crud.user import get_user_by_telegram_id, add_user_balance from app.external.tribute import TributeService as TributeAPI -from app.localization.texts import get_texts +from app.services.payment_service import PaymentService logger = logging.getLogger(__name__) @@ -216,7 +214,7 @@ class TributeService: logger.error(f"Ошибка обработки возврата Tribute: {e}") async def _send_success_notification(self, user_id: int, amount_kopeks: int): - + try: amount_rubles = amount_kopeks / 100 @@ -224,34 +222,8 @@ class TributeService: user = await get_user_by_telegram_id(session, user_id) break - user_language = user.language if user else "ru" - texts = get_texts(user_language) - - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ) - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] - ] - ) + payment_service = PaymentService(self.bot) + keyboard = await payment_service.build_topup_success_keyboard(user) text = ( f"✅ **Платеж успешно получен!**\n\n" @@ -267,10 +239,11 @@ class TributeService: reply_markup=keyboard, parse_mode="Markdown" ) - + except Exception as e: logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}") - + + async def _send_failure_notification(self, user_id: int): try: diff --git a/app/services/user_service.py b/app/services/user_service.py index f26e14b2..c2dcd37f 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -145,13 +145,14 @@ class UserService: db: AsyncSession, page: int = 1, limit: int = 20, - status: Optional[UserStatus] = None + status: Optional[UserStatus] = None, + order_by_balance: bool = False ) -> Dict[str, Any]: try: offset = (page - 1) * limit users = await get_users_list( - db, offset=offset, limit=limit, status=status + db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance ) total_count = await get_users_count(db, status=status) diff --git a/app/states.py b/app/states.py index b0479c08..01a14206 100644 --- a/app/states.py +++ b/app/states.py @@ -16,6 +16,7 @@ class SubscriptionStates(StatesGroup): adding_devices = State() extending_subscription = State() confirming_traffic_reset = State() + cart_saved_for_topup = State() class BalanceStates(StatesGroup): waiting_for_amount = State() @@ -41,6 +42,23 @@ class AdminStates(StatesGroup): setting_promocode_value = State() setting_promocode_uses = State() setting_promocode_expiry = State() + + creating_campaign_name = State() + creating_campaign_start = State() + creating_campaign_bonus = State() + creating_campaign_balance = State() + creating_campaign_subscription_days = State() + creating_campaign_subscription_traffic = State() + creating_campaign_subscription_devices = State() + creating_campaign_subscription_servers = State() + + editing_campaign_name = State() + editing_campaign_start = State() + editing_campaign_balance = State() + editing_campaign_subscription_days = State() + editing_campaign_subscription_traffic = State() + editing_campaign_subscription_devices = State() + editing_campaign_subscription_servers = State() waiting_for_broadcast_message = State() waiting_for_broadcast_media = State() @@ -69,6 +87,9 @@ class AdminStates(StatesGroup): editing_welcome_text = State() waiting_for_message_buttons = "waiting_for_message_buttons" + + # Состояния для отслеживания источника перехода + viewing_user_from_balance_list = State() class SupportStates(StatesGroup): waiting_for_message = State() diff --git a/app/utils/cache.py b/app/utils/cache.py index 408f7246..aeed54f7 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -203,14 +203,19 @@ class UserCache: @staticmethod async def set_user_session( - user_id: int, - session_key: str, - data: Any, + user_id: int, + session_key: str, + data: Any, expire: int = 1800 ) -> bool: key = cache_key("session", user_id, session_key) return await cache.set(key, data, expire) + @staticmethod + async def delete_user_session(user_id: int, session_key: str) -> bool: + key = cache_key("session", user_id, session_key) + return await cache.delete(key) + class SystemCache: diff --git a/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py new file mode 100644 index 00000000..31a943b8 --- /dev/null +++ b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py @@ -0,0 +1,70 @@ +"""add advertising campaigns tables""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "5d1f1f8b2e9a" +down_revision: Union[str, None] = "cbd1be472f3d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "advertising_campaigns", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("start_parameter", sa.String(length=64), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True), + sa.Column("subscription_device_limit", sa.Integer(), nullable=True), + sa.Column("subscription_squads", sa.JSON(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + op.create_index( + "ix_advertising_campaigns_start_parameter", + "advertising_campaigns", + ["start_parameter"], + unique=True, + ) + op.create_index( + "ix_advertising_campaigns_id", + "advertising_campaigns", + ["id"], + ) + + op.create_table( + "advertising_campaign_registrations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("campaign_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["campaign_id"], ["advertising_campaigns.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), + ) + op.create_index( + "ix_advertising_campaign_registrations_id", + "advertising_campaign_registrations", + ["id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_advertising_campaign_registrations_id", table_name="advertising_campaign_registrations") + op.drop_table("advertising_campaign_registrations") + op.drop_index("ix_advertising_campaigns_id", table_name="advertising_campaigns") + op.drop_index("ix_advertising_campaigns_start_parameter", table_name="advertising_campaigns") + op.drop_table("advertising_campaigns") diff --git a/requirements.txt b/requirements.txt index 986b9f10..95294b91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,29 +1,29 @@ # Основные зависимости -aiogram==3.7.0 -aiohttp==3.9.1 -asyncpg==0.29.0 -SQLAlchemy==2.0.25 -alembic==1.13.1 -aiosqlite==0.19.0 +aiogram==3.22.0 +aiohttp==3.12.15 +asyncpg==0.30.0 +SQLAlchemy==2.0.43 +alembic==1.16.5 +aiosqlite==0.21.0 # Дополнительные зависимости -pydantic==2.5.3 -pydantic-settings==2.1.0 -python-dotenv==1.0.0 +pydantic==2.11.9 +pydantic-settings==2.10.1 +python-dotenv==1.1.1 redis==5.0.1 PyYAML==6.0.2 # YooKassa SDK -yookassa==3.0.0 +yookassa==3.7.0 # Логирование и мониторинг structlog==23.2.0 # Планировщик задач для техработ -APScheduler==3.10.4 +APScheduler==3.11.0 # Утилиты -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 pytz==2023.4 cryptography>=41.0.0 qrcode[pil]==7.4.2