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