commit 7c7c88621db5ad08fa4362829233c40932827043 Author: smolkik-code Date: Sun Jul 5 17:50:11 2026 +0700 Initial commit: VPN Control Panel - FastAPI REST API with JWT auth - aiogram 3 Telegram bot with admin middleware - APScheduler daily tasks (expiry, reminders, revoke, sync) - SQLAlchemy 2 async ORM with Alembic migrations - Jinja2 admin panel (Dashboard, Users, Payments, Servers, Tariffs) - VPN provider abstraction with MockProvider - Stats service with revenue/subscription analytics - Docker Compose (PostgreSQL + Redis + app) - Healthcheck endpoint diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6082c6f --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# App +APP_NAME=VPN Control Panel +DEBUG=false +PORT=8000 + +# Database +POSTGRES_USER=vpn +POSTGRES_PASSWORD=vpn_secret +POSTGRES_DB=vpn_control +POSTGRES_HOST=db +POSTGRES_PORT=5432 +DATABASE_URL=postgresql+asyncpg://vpn:vpn_secret@db:5432/vpn_control + +# Redis +REDIS_URL=redis://redis:6379/0 + +# Telegram Bot +BOT_TOKEN=your_bot_token_here + +# Admin +ADMIN_IDS=[] +ADMIN_GROUP_ID=0 +ADMIN_GROUP_THREAD_ID= +JWT_SECRET=change_me_to_random_string +JWT_ACCESS_EXPIRE_MINUTES=30 +JWT_REFRESH_EXPIRE_DAYS=30 + +# Payment +# (provider-specific keys) + +# VPN Providers +OUTLINE_API_PREFIX= +OUTLINE_CERT_SHA256= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a5cfee --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg + +# Virtual environments +.venv/ +venv/ +.env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project +.env +*.db +*.sqlite3 + +# Docker +docker-data/ + +# Logs +*.log +logs/ + +# Migrations cache +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b465f4 --- /dev/null +++ b/README.md @@ -0,0 +1,276 @@ +# VPN Control Panel + +Система управления VPN-подписками: регистрация пользователей, тарифы, платежи, +Telegram-бот, административная панель и фоновые задачи. + +## Стек + +- **Python 3.13** / **FastAPI** + Uvicorn +- **SQLAlchemy 2** (async) + asyncpg +- **PostgreSQL 16** / **Redis 7** +- **Alembic** — миграции БД +- **aiogram 3** — Telegram-бот +- **APScheduler** — фоновые задачи +- **Jinja2** — админ-панель +- **Docker** / **Docker Compose** + +## Структура + +``` +├── app/ +│ ├── api/v1/ # REST API endpoints (users, payments, tariffs, servers, auth, stats) +│ ├── admin/ # Административная панель (Jinja2) +│ ├── bot/ # Telegram bot (handlers, middleware, dispatcher) +│ ├── models/ # SQLAlchemy ORM модели +│ ├── providers/ # VPN-провайдеры (абстракция + Mock) +│ ├── repositories/ # Слой доступа к данным (CRUD) +│ ├── scheduler/ # Фоновые задачи (APScheduler) +│ ├── schemas/ # Pydantic схемы запросов/ответов +│ ├── services/ # Бизнес-логика +│ └── utils/ # Вспомогательные функции +├── docker/ +│ ├── Dockerfile # Production-сборка +│ ├── Dockerfile.dev # Dev-сборка +│ └── entrypoint.sh # Точка входа (миграции + uvicorn) +├── migrations/ # Alembic миграции +├── tests/ # Тесты +├── .env.example +├── docker-compose.yml +└── docker-compose.dev.yml +``` + +## Быстрый старт + +### 1. Запуск через Docker (рекомендуется) + +```bash +cp .env.example .env +# Отредактируйте .env (BOT_TOKEN, JWT_SECRET и т.д.) +docker compose up -d +``` + +Приложение будет доступно на `http://localhost:8000`. + +### 2. Локальный запуск для разработки + +```bash +# Только БД и Redis +docker compose -f docker-compose.dev.yml up -d + +# Python окружение +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.dev.txt + +# Миграции +alembic upgrade head + +# Запуск +uvicorn app.main:app --reload --port 8000 +``` + +## Миграции + +Создание новой миграции: + +```bash +alembic revision --autogenerate -m "description" +``` + +Применение миграций: + +```bash +alembic upgrade head +``` + +Откат на одну: + +```bash +alembic downgrade -1 +``` + +## Создание администратора + +Администратор создаётся напрямую в БД. Telegram ID — ваш числовой ID в Telegram. + +```bash +docker compose exec app python -c " +import asyncio +from app.database import async_session_factory +from app.repositories.admin import AdminRepository +from app.models.admin import AdminRole + +async def create(): + async with async_session_factory() as session: + repo = AdminRepository(session) + admin = await repo.create( + telegram_id=123456789, # Ваш Telegram ID + username='admin', + role=AdminRole.SUPERADMIN, + is_active=True, + ) + print(f'Admin created: id={admin.id} tg={admin.telegram_id}') + +asyncio.run(create()) +" +``` + +## Настройка Telegram + +1. Создайте бота через [@BotFather](https://t.me/BotFather), получите токен. +2. Укажите токен в `.env`: `BOT_TOKEN=ваш_токен`. +3. Узнайте свой Telegram ID (например, через @userinfobot). +4. Добавьте его в БД через скрипт выше. +5. Запустите — бот ответит на команды только администраторам из БД. + +Команды бота: + +``` +/start — приветствие +/help — список команд +/users — список активных пользователей +/user — информация о пользователе +/delete — деактивация пользователя +/renew — статус подписки +/expired — список просроченных подписок +/expiring [N] — истекают в ближайшие N дней (по умолч. 3) +/stats — статистика системы +``` + +## Пример .env + +```env +APP_NAME=VPN Control Panel +DEBUG=false +PORT=8000 + +POSTGRES_USER=vpn +POSTGRES_PASSWORD=vpn_secret +POSTGRES_DB=vpn_control +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +REDIS_URL=redis://redis:6379/0 + +BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 +ADMIN_IDS=[123456789] +ADMIN_GROUP_ID=-1001234567890 +ADMIN_GROUP_THREAD_ID= + +JWT_SECRET=случайная_строка_32_символа +JWT_ACCESS_EXPIRE_MINUTES=30 +JWT_REFRESH_EXPIRE_DAYS=30 + +OUTLINE_API_PREFIX=https://example.com:1234/abc123 +OUTLINE_CERT_SHA256=sha256hash... +``` + +## Пример API + +Авторизация: + +```bash +# Логин (secret_key = JWT_SECRET из .env) +curl -X POST http://localhost:8000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"telegram_id": 123456789, "secret_key": "change_me_to_random_string"}' + +# Ответ: +# {"access_token": "...", "refresh_token": "...", "token_type": "bearer"} +``` + +Пользователи: + +```bash +# Список (требуется Bearer token) +curl http://localhost:8000/api/v1/users \ + -H "Authorization: Bearer " + +# Создать +curl -X POST http://localhost:8000/api/v1/users \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"telegram_id": 987654321, "username": "user", "full_name": "User Name"}' +``` + +Тарифы: + +```bash +curl http://localhost:8000/api/v1/tariffs +# [{"id":1,"name":"Basic","duration_days":30,"price":500.0,"currency":"RUB",...}] +``` + +Платежи: + +```bash +curl -X POST http://localhost:8000/api/v1/payments \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user_id": 1, "tariff_id": 1, "amount": 500, "provider": "manual"}' + +# Подтвердить платёж (активирует подписку) +curl -X PATCH http://localhost:8000/api/v1/payments/1/confirm \ + -H "Authorization: Bearer " +``` + +Статистика: + +```bash +curl http://localhost:8000/api/v1/stats +# {"total_users":5,"active_users":3,"total_revenue":2500.0,...} +``` + +Административная панель: + +``` +http://localhost:8000/admin/ — Dashboard +http://localhost:8000/admin/users — Пользователи +http://localhost:8000/admin/payments — Платежи +http://localhost:8000/admin/servers — Серверы +http://localhost:8000/admin/tariffs — Тарифы +``` + +Healthcheck: + +```bash +curl http://localhost:8000/health +# {"status":"ok","database":"connected"} +``` + +Документация API (Swagger): + +``` +http://localhost:8000/docs +http://localhost:8000/redoc +``` + +## Архитектура + +``` +Client → FastAPI (REST / Admin) + ↓ + Service Layer + ↓ + Repository Layer + ↓ + PostgreSQL / Redis + ↓ +Telegram Bot ← aiogram ← APScheduler (daily tasks) +``` + +- **Service Layer** — бизнес-логика, не содержит SQL. +- **Repository Layer** — только CRUD, без логики. +- Сервисы не вызывают друг друга — оркестрация на уровне handler. +- Telegram-бот не содержит бизнес-логики — только отображение. +- Scheduler не содержит Telegram-кода — только вызовы сервисов. + +## Планы + +- WireGuard / Outline / XRay провайдеры +- Онлайн-оплата (ЮKassa, Stripe) +- Рассылка уведомлений +- Dashboard с графиками + +## Лицензия + +MIT diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..06eeb64 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/__init__.py b/app/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/router.py b/app/admin/router.py new file mode 100644 index 0000000..f638b94 --- /dev/null +++ b/app/admin/router.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import func, select + +from app.database import async_session_factory +from app.models.payment import Payment, PaymentStatus +from app.models.server import Server +from app.models.tariff import Tariff +from app.models.user import User +from app.repositories.payment import PaymentRepository +from app.repositories.server import ServerRepository +from app.repositories.tariff import TariffRepository +from app.repositories.user import UserRepository + +router = APIRouter(prefix="/admin", tags=["admin"]) +templates = Jinja2Templates(directory="app/templates") + +router = APIRouter(prefix="/admin", tags=["admin"]) +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/", response_class=HTMLResponse) +async def dashboard(request: Request): + async with async_session_factory() as session: + + async def _count(model, *filters): + stmt = select(func.count(model.id)) + for f in filters: + stmt = stmt.where(f) + return (await session.execute(stmt)).scalar_one() + + total_users = await _count(User) + active_users = await _count(User, User.is_active.is_(True)) + total_tariffs = await _count(Tariff) + total_servers = await _count(Server) + active_servers = await _count(Server, Server.is_active.is_(True)) + total_payments = await _count(Payment) + confirmed_payments = await _count( + Payment, Payment.status == PaymentStatus.CONFIRMED + ) + + rev_stmt = select(func.coalesce(func.sum(Payment.amount), 0)).where( + Payment.status == PaymentStatus.CONFIRMED + ) + total_revenue = float((await session.execute(rev_stmt)).scalar_one()) + + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "total_users": total_users, + "active_users": active_users, + "total_tariffs": total_tariffs, + "total_servers": total_servers, + "active_servers": active_servers, + "total_payments": total_payments, + "confirmed_payments": confirmed_payments, + "total_revenue": total_revenue, + }, + ) + + +@router.get("/users", response_class=HTMLResponse) +async def users_page(request: Request): + async with async_session_factory() as session: + repo = UserRepository(session) + users = await repo.get_all() + + return templates.TemplateResponse( + "users.html", + {"request": request, "users": users}, + ) + + +@router.get("/payments", response_class=HTMLResponse) +async def payments_page(request: Request): + async with async_session_factory() as session: + tariff_repo = TariffRepository(session) + + stmt = select(Payment).order_by(Payment.created_at.desc()) + result = await session.execute(stmt) + payments = list(result.scalars().all()) + + tariffs_map = {} + for p in payments: + if p.tariff_id not in tariffs_map: + t = await tariff_repo.get(p.tariff_id) + tariffs_map[p.tariff_id] = t.name if t else "—" + + return templates.TemplateResponse( + "payments.html", + {"request": request, "payments": payments, "tariffs_map": tariffs_map}, + ) + + +@router.get("/servers", response_class=HTMLResponse) +async def servers_page(request: Request): + async with async_session_factory() as session: + repo = ServerRepository(session) + servers = await repo.get_all() + + return templates.TemplateResponse( + "servers.html", + {"request": request, "servers": servers}, + ) + + +@router.get("/tariffs", response_class=HTMLResponse) +async def tariffs_page(request: Request): + async with async_session_factory() as session: + repo = TariffRepository(session) + tariffs = await repo.get_all() + + return templates.TemplateResponse( + "tariffs.html", + {"request": request, "tariffs": tariffs}, + ) diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..b62dec9 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,140 @@ +from collections.abc import AsyncGenerator + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import async_session_factory +from app.repositories import ( + UserRepository, + PaymentRepository, + ServerRepository, + TariffRepository, + NotificationRepository, + AdminRepository, +) +from app.services import ( + UserService, + PaymentService, + BillingService, + NotificationService, + ServerService, + VPNService, +) +from app.services.auth import AuthService + +security = HTTPBearer() + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +def get_user_service(db: AsyncSession = Depends(get_db)) -> UserService: + return UserService(UserRepository(db)) + + +def get_payment_service(db: AsyncSession = Depends(get_db)) -> PaymentService: + return PaymentService( + PaymentRepository(db), + UserRepository(db), + TariffRepository(db), + ) + + +def get_billing_service(db: AsyncSession = Depends(get_db)) -> BillingService: + return BillingService( + PaymentRepository(db), + TariffRepository(db), + UserRepository(db), + ) + + +def get_notification_service( + db: AsyncSession = Depends(get_db), +) -> NotificationService: + return NotificationService( + NotificationRepository(db), + UserRepository(db), + ) + + +def get_server_service(db: AsyncSession = Depends(get_db)) -> ServerService: + return ServerService(ServerRepository(db)) + + +def get_vpn_service(db: AsyncSession = Depends(get_db)) -> VPNService: + return VPNService( + ServerRepository(db), + UserRepository(db), + ) + + +def get_tariff_repo(db: AsyncSession = Depends(get_db)) -> TariffRepository: + return TariffRepository(db) + + +def get_server_repo(db: AsyncSession = Depends(get_db)) -> ServerRepository: + return ServerRepository(db) + + +def get_auth_service(db: AsyncSession = Depends(get_db)) -> AuthService: + return AuthService(AdminRepository(db)) + + +async def get_current_admin( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> dict: + from app.services.auth import AuthService + + auth_svc = AuthService(AdminRepository(db)) + + try: + payload = auth_svc.decode_token(credentials.credentials) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type", + ) + + telegram_id = int(payload["sub"]) + admin = await AdminRepository(db).get_by_telegram_id(telegram_id) + + if admin is None or not admin.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin not found or deactivated", + ) + + return { + "admin_id": admin.id, + "telegram_id": admin.telegram_id, + "role": admin.role.value, + "username": admin.username, + } + + +def require_admin(payload: dict = Depends(get_current_admin)) -> dict: + return payload + + +def require_superadmin(payload: dict = Depends(get_current_admin)) -> dict: + if payload["role"] != "superadmin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Superadmin role required", + ) + return payload diff --git a/app/api/middleware.py b/app/api/middleware.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/admin.py b/app/api/v1/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..b8ed152 --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from app.api.deps import get_auth_service +from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse +from app.services.auth import AuthService + +router = APIRouter(prefix="/auth", tags=["Auth"]) + + +@router.post("/login", response_model=TokenResponse) +async def login( + body: LoginRequest, + auth_svc: AuthService = Depends(get_auth_service), +): + result = await auth_svc.authenticate(body.telegram_id, body.secret_key) + if result is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + return result + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh( + body: RefreshRequest, + auth_svc: AuthService = Depends(get_auth_service), +): + try: + return auth_svc.refresh_access_token(body.refresh_token) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) diff --git a/app/api/v1/payments.py b/app/api/v1/payments.py new file mode 100644 index 0000000..4ec9017 --- /dev/null +++ b/app/api/v1/payments.py @@ -0,0 +1,107 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from app.api.deps import ( + get_payment_service, + get_billing_service, + get_notification_service, +) +from app.schemas.payment import PaymentRead, PaymentCreate, PaymentConfirm +from app.services import PaymentService, BillingService, NotificationService + +router = APIRouter(prefix="/payments", tags=["Payments"]) + + +@router.post("", response_model=PaymentRead, status_code=status.HTTP_201_CREATED) +async def create_payment( + body: PaymentCreate, + payment_svc: PaymentService = Depends(get_payment_service), +): + try: + return await payment_svc.create( + user_id=body.user_id, + tariff_id=body.tariff_id, + provider=body.provider, + amount=body.amount, + currency=body.currency, + external_id=body.external_id, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{payment_id}", response_model=PaymentRead) +async def get_payment( + payment_id: int, + payment_svc: PaymentService = Depends(get_payment_service), +): + payment = await payment_svc.get_by_id(payment_id) + if payment is None: + raise HTTPException(status_code=404, detail="Payment not found") + return payment + + +@router.get("/by-external/{external_id}", response_model=PaymentRead) +async def get_payment_by_external( + external_id: str, + payment_svc: PaymentService = Depends(get_payment_service), +): + payment = await payment_svc.get_by_external_id(external_id) + if payment is None: + raise HTTPException(status_code=404, detail="Payment not found") + return payment + + +@router.post("/{payment_id}/confirm", response_model=PaymentRead) +async def confirm_payment( + payment_id: int, + body: PaymentConfirm = PaymentConfirm(), + payment_svc: PaymentService = Depends(get_payment_service), + billing_svc: BillingService = Depends(get_billing_service), + notification_svc: NotificationService = Depends(get_notification_service), +): + try: + payment = await payment_svc.confirm(payment_id, body.external_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + await billing_svc.activate_subscription( + payment.user_id, payment.tariff_id, payment.id + ) + + await notification_svc.send_success( + payment.user_id, + "Payment confirmed", + f"Payment {payment.id} confirmed. Subscription activated.", + ) + + return payment + + +@router.post("/{payment_id}/fail", response_model=PaymentRead) +async def fail_payment( + payment_id: int, + payment_svc: PaymentService = Depends(get_payment_service), +): + try: + return await payment_svc.fail(payment_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/{payment_id}/refund", response_model=PaymentRead) +async def refund_payment( + payment_id: int, + payment_svc: PaymentService = Depends(get_payment_service), +): + try: + return await payment_svc.refund(payment_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/user/{user_id}", response_model=list[PaymentRead]) +async def get_user_payments( + user_id: int, + payment_svc: PaymentService = Depends(get_payment_service), +): + return await payment_svc.get_user_payments(user_id) diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..14149d3 --- /dev/null +++ b/app/api/v1/router.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from app.api.v1.auth import router as auth_router +from app.api.v1.users import router as users_router +from app.api.v1.payments import router as payments_router +from app.api.v1.tariffs import router as tariffs_router +from app.api.v1.servers import router as servers_router +from app.api.v1.stats import router as stats_router + +router = APIRouter(prefix="/api/v1") + +router.include_router(auth_router) +router.include_router(users_router) +router.include_router(payments_router) +router.include_router(tariffs_router) +router.include_router(servers_router) +router.include_router(stats_router) diff --git a/app/api/v1/servers.py b/app/api/v1/servers.py new file mode 100644 index 0000000..84311e0 --- /dev/null +++ b/app/api/v1/servers.py @@ -0,0 +1,66 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from app.api.deps import get_server_service, get_server_repo +from app.repositories.server import ServerRepository +from app.schemas.server import ServerRead, ServerCreate, ServerUpdate +from app.services.server import ServerService + +router = APIRouter(prefix="/servers", tags=["Servers"]) + + +@router.get("", response_model=list[ServerRead]) +async def list_servers( + server_svc: ServerService = Depends(get_server_service), +): + return await server_svc.get_active() + + +@router.get("/all", response_model=list[ServerRead]) +async def list_all_servers( + server_repo: ServerRepository = Depends(get_server_repo), +): + return await server_repo.get_all() + + +@router.post("", response_model=ServerRead, status_code=status.HTTP_201_CREATED) +async def create_server( + body: ServerCreate, + server_svc: ServerService = Depends(get_server_service), +): + try: + return await server_svc.create( + name=body.name, + host=body.host, + port=body.port, + protocol=body.protocol, + location=body.location, + country_code=body.country_code, + max_users=body.max_users, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{server_id}", response_model=ServerRead) +async def get_server( + server_id: int, + server_svc: ServerService = Depends(get_server_service), +): + server = await server_svc.get_by_id(server_id) + if server is None: + raise HTTPException(status_code=404, detail="Server not found") + return server + + +@router.patch("/{server_id}", response_model=ServerRead) +async def update_server( + server_id: int, + body: ServerUpdate, + server_svc: ServerService = Depends(get_server_service), +): + server = await server_svc.update( + server_id, **body.model_dump(exclude_unset=True) + ) + if server is None: + raise HTTPException(status_code=404, detail="Server not found") + return server diff --git a/app/api/v1/stats.py b/app/api/v1/stats.py new file mode 100644 index 0000000..ff79267 --- /dev/null +++ b/app/api/v1/stats.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db +from app.models.payment import Payment, PaymentStatus +from app.models.server import Server +from app.models.tariff import Tariff +from app.models.user import User +from app.schemas.stats import StatsResponse + +router = APIRouter(prefix="/stats", tags=["Stats"]) + + +@router.get("", response_model=StatsResponse) +async def get_stats( + db: AsyncSession = Depends(get_db), +): + stmt = select(func.count(User.id)) + total_users = (await db.execute(stmt)).scalar_one() + + stmt = select(func.count(User.id)).where(User.is_active.is_(True)) + active_users = (await db.execute(stmt)).scalar_one() + + stmt = select(func.count(Tariff.id)) + total_tariffs = (await db.execute(stmt)).scalar_one() + + stmt = select(func.count(Tariff.id)).where(Tariff.is_active.is_(True)) + active_tariffs = (await db.execute(stmt)).scalar_one() + + stmt = select(func.count(Server.id)) + total_servers = (await db.execute(stmt)).scalar_one() + + stmt = select(func.count(Server.id)).where(Server.is_active.is_(True)) + active_servers = (await db.execute(stmt)).scalar_one() + + stmt = select(func.count(Payment.id)) + total_payments = (await db.execute(stmt)).scalar_one() + + stmt = ( + select(func.count(Payment.id)) + .where(Payment.status == PaymentStatus.CONFIRMED) + ) + confirmed_payments = (await db.execute(stmt)).scalar_one() + + stmt = ( + select(func.count(Payment.id)) + .where(Payment.status == PaymentStatus.PENDING) + ) + pending_payments = (await db.execute(stmt)).scalar_one() + + stmt = ( + select(func.coalesce(func.sum(Payment.amount), 0)) + .where(Payment.status == PaymentStatus.CONFIRMED) + ) + total_revenue = float((await db.execute(stmt)).scalar_one()) + + return StatsResponse( + total_users=total_users, + active_users=active_users, + total_tariffs=total_tariffs, + active_tariffs=active_tariffs, + total_servers=total_servers, + active_servers=active_servers, + total_payments=total_payments, + confirmed_payments=confirmed_payments, + pending_payments=pending_payments, + total_revenue=round(total_revenue, 2), + ) diff --git a/app/api/v1/subscriptions.py b/app/api/v1/subscriptions.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/tariffs.py b/app/api/v1/tariffs.py new file mode 100644 index 0000000..637ff02 --- /dev/null +++ b/app/api/v1/tariffs.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.api.deps import get_tariff_repo +from app.repositories.tariff import TariffRepository +from app.schemas.tariff import TariffRead + +router = APIRouter(prefix="/tariffs", tags=["Tariffs"]) + + +@router.get("", response_model=list[TariffRead]) +async def list_tariffs( + tariff_repo: TariffRepository = Depends(get_tariff_repo), +): + return await tariff_repo.get_active_tariffs() + + +@router.get("/all", response_model=list[TariffRead]) +async def list_all_tariffs( + tariff_repo: TariffRepository = Depends(get_tariff_repo), +): + return await tariff_repo.get_all() + + +@router.get("/{tariff_id}", response_model=TariffRead) +async def get_tariff( + tariff_id: int, + tariff_repo: TariffRepository = Depends(get_tariff_repo), +): + tariff = await tariff_repo.get(tariff_id) + if tariff is None: + raise HTTPException(status_code=404, detail="Tariff not found") + return tariff diff --git a/app/api/v1/users.py b/app/api/v1/users.py new file mode 100644 index 0000000..fead5e6 --- /dev/null +++ b/app/api/v1/users.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.api.deps import ( + get_user_service, + get_billing_service, +) +from app.schemas.user import UserRead, UserUpdate +from app.services import UserService, BillingService + +router = APIRouter(prefix="/users", tags=["Users"]) + + +@router.get("/{user_id}", response_model=UserRead) +async def get_user( + user_id: int, + user_svc: UserService = Depends(get_user_service), +): + user = await user_svc.get_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch("/{user_id}", response_model=UserRead) +async def update_user( + user_id: int, + body: UserUpdate, + user_svc: UserService = Depends(get_user_service), +): + user = await user_svc.update(user_id, **body.model_dump(exclude_unset=True)) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.post("/{user_id}/deactivate", response_model=UserRead) +async def deactivate_user( + user_id: int, + user_svc: UserService = Depends(get_user_service), +): + ok = await user_svc.deactivate(user_id) + if not ok: + raise HTTPException(status_code=404, detail="User not found") + return await user_svc.get_by_id(user_id) + + +@router.post("/{user_id}/activate", response_model=UserRead) +async def activate_user( + user_id: int, + user_svc: UserService = Depends(get_user_service), +): + ok = await user_svc.activate(user_id) + if not ok: + raise HTTPException(status_code=404, detail="User not found") + return await user_svc.get_by_id(user_id) + + +@router.get("/by-telegram/{telegram_id}", response_model=UserRead) +async def get_user_by_telegram( + telegram_id: int, + user_svc: UserService = Depends(get_user_service), +): + user = await user_svc.get_by_telegram_id(telegram_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.get("/{user_id}/subscription") +async def get_user_subscription( + user_id: int, + billing_svc: BillingService = Depends(get_billing_service), +): + info = await billing_svc.get_active_subscription(user_id) + if info is None: + raise HTTPException(status_code=404, detail="User not found") + return { + "is_active": info.is_active, + "tariff_name": info.tariff.name if info.tariff else None, + "start_date": info.start_date, + "end_date": info.end_date, + "remaining_days": info.remaining_days, + } diff --git a/app/api/v1/vpn.py b/app/api/v1/vpn.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/__init__.py b/app/bot/__init__.py new file mode 100644 index 0000000..5ea28b8 --- /dev/null +++ b/app/bot/__init__.py @@ -0,0 +1,3 @@ +from app.bot.dispatcher import create_bot, create_dispatcher + +__all__ = ["create_bot", "create_dispatcher"] diff --git a/app/bot/dispatcher.py b/app/bot/dispatcher.py new file mode 100644 index 0000000..17ccf86 --- /dev/null +++ b/app/bot/dispatcher.py @@ -0,0 +1,30 @@ +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode + +from app.bot.handlers.start import router as start_router +from app.bot.handlers.users import router as users_router +from app.bot.handlers.subscription import router as subscription_router +from app.bot.handlers.stats import router as stats_router +from app.bot.middlewares import AdminMiddleware +from app.settings import settings + + +def create_dispatcher() -> Dispatcher: + dp = Dispatcher() + + dp.message.middleware(AdminMiddleware()) + + dp.include_router(start_router) + dp.include_router(users_router) + dp.include_router(subscription_router) + dp.include_router(stats_router) + + return dp + + +def create_bot() -> Bot: + return Bot( + token=settings.bot_token, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) diff --git a/app/bot/handlers/__init__.py b/app/bot/handlers/__init__.py new file mode 100644 index 0000000..e6b070f --- /dev/null +++ b/app/bot/handlers/__init__.py @@ -0,0 +1,6 @@ +from app.bot.handlers.start import router as start_router +from app.bot.handlers.users import router as users_router +from app.bot.handlers.subscription import router as subscription_router +from app.bot.handlers.stats import router as stats_router + +__all__ = ["start_router", "users_router", "subscription_router", "stats_router"] diff --git a/app/bot/handlers/admin.py b/app/bot/handlers/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/handlers/payment.py b/app/bot/handlers/payment.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/handlers/start.py b/app/bot/handlers/start.py new file mode 100644 index 0000000..c175077 --- /dev/null +++ b/app/bot/handlers/start.py @@ -0,0 +1,17 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message + +from app.bot.texts import START, HELP + +router = Router(name="start") + + +@router.message(Command("start")) +async def cmd_start(message: Message) -> None: + await message.answer(START, parse_mode="HTML") + + +@router.message(Command("help")) +async def cmd_help(message: Message) -> None: + await message.answer(HELP, parse_mode="HTML") diff --git a/app/bot/handlers/stats.py b/app/bot/handlers/stats.py new file mode 100644 index 0000000..5342e07 --- /dev/null +++ b/app/bot/handlers/stats.py @@ -0,0 +1,55 @@ +from sqlalchemy import func, select + +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message + +from app.bot.texts import stats_text +from app.database import async_session_factory +from app.models.payment import Payment, PaymentStatus +from app.models.server import Server +from app.models.tariff import Tariff +from app.models.user import User + +router = Router(name="stats") + + +@router.message(Command("stats")) +async def cmd_stats(message: Message) -> None: + async with async_session_factory() as session: + + async def _count(model, *filters) -> int: + stmt = select(func.count(model.id)) + for f in filters: + stmt = stmt.where(f) + return (await session.execute(stmt)).scalar_one() + + total_users = await _count(User) + active_users = await _count(User, User.is_active.is_(True)) + total_tariffs = await _count(Tariff) + total_servers = await _count(Server) + active_servers = await _count(Server, Server.is_active.is_(True)) + total_payments = await _count(Payment) + confirmed_payments = await _count( + Payment, Payment.status == PaymentStatus.CONFIRMED + ) + + rev_stmt = ( + select(func.coalesce(func.sum(Payment.amount), 0)) + .where(Payment.status == PaymentStatus.CONFIRMED) + ) + total_revenue = float((await session.execute(rev_stmt)).scalar_one()) + + await message.answer( + stats_text( + total_users=total_users, + active_users=active_users, + total_tariffs=total_tariffs, + total_servers=total_servers, + active_servers=active_servers, + total_payments=total_payments, + confirmed_payments=confirmed_payments, + total_revenue=total_revenue, + ), + parse_mode="HTML", + ) diff --git a/app/bot/handlers/subscription.py b/app/bot/handlers/subscription.py new file mode 100644 index 0000000..c3ec2f5 --- /dev/null +++ b/app/bot/handlers/subscription.py @@ -0,0 +1,134 @@ +from datetime import datetime, timedelta, timezone + +from aiogram import Router +from aiogram.filters import Command, CommandObject +from aiogram.types import Message +from loguru import logger + +from app.bot.texts import ( + user_info, + USER_NOT_FOUND, + INVALID_ARGS, + NO_DATA, + expired_list, + expiring_list, +) +from app.database import async_session_factory +from app.models.payment import PaymentStatus +from app.repositories.payment import PaymentRepository +from app.repositories.tariff import TariffRepository +from app.repositories.user import UserRepository +from app.services.billing import BillingService + +router = Router(name="subscription") + + +@router.message(Command("renew")) +async def cmd_renew(message: Message, command: CommandObject) -> None: + if not command.args or not command.args.strip().isdigit(): + await message.answer( + INVALID_ARGS.format("/renew "), + parse_mode="HTML", + ) + return + + telegram_id = int(command.args.strip()) + + async with async_session_factory() as session: + user_repo = UserRepository(session) + user = await user_repo.get_by_telegram_id(telegram_id) + + if user is None: + await message.answer( + USER_NOT_FOUND.format(telegram_id), parse_mode="HTML" + ) + return + + billing = BillingService( + PaymentRepository(session), + TariffRepository(session), + user_repo, + ) + sub = await billing.get_active_subscription(user.id) + + await message.answer( + user_info( + user_id=user.id, + telegram_id=user.telegram_id, + username=user.username, + full_name=user.full_name, + is_active=user.is_active, + created_at=user.created_at.strftime("%Y-%m-%d"), + subscription_active=sub is not None and sub.is_active, + tariff_name=sub.tariff.name if sub and sub.tariff else None, + remaining_days=sub.remaining_days if sub else 0, + ), + parse_mode="HTML", + ) + + +@router.message(Command("expired")) +async def cmd_expired(message: Message) -> None: + async with async_session_factory() as session: + payment_repo = PaymentRepository(session) + tariff_repo = TariffRepository(session) + user_repo = UserRepository(session) + + confirmed = await payment_repo.get_by_status(PaymentStatus.CONFIRMED) + now = datetime.now(timezone.utc) + items = [] + + for payment in confirmed: + tariff = await tariff_repo.get(payment.tariff_id) + if tariff is None: + continue + start = payment.paid_at or payment.created_at + end = start + timedelta(days=tariff.duration_days) + if now > end: + user = await user_repo.get(payment.user_id) + items.append( + { + "user_id": payment.user_id, + "telegram_id": user.telegram_id if user else "?", + "tariff_name": tariff.name, + } + ) + + await message.answer(expired_list(items), parse_mode="HTML") + + +@router.message(Command("expiring")) +async def cmd_expiring(message: Message, command: CommandObject) -> None: + days = 3 + if command.args and command.args.strip().isdigit(): + days = int(command.args.strip()) + + async with async_session_factory() as session: + payment_repo = PaymentRepository(session) + tariff_repo = TariffRepository(session) + user_repo = UserRepository(session) + + confirmed = await payment_repo.get_by_status(PaymentStatus.CONFIRMED) + now = datetime.now(timezone.utc) + items = [] + + for payment in confirmed: + tariff = await tariff_repo.get(payment.tariff_id) + if tariff is None: + continue + start = payment.paid_at or payment.created_at + end = start + timedelta(days=tariff.duration_days) + remaining = (end - now).days + if 0 <= remaining <= days: + user = await user_repo.get(payment.user_id) + items.append( + { + "user_id": payment.user_id, + "telegram_id": user.telegram_id if user else "?", + "tariff_name": tariff.name, + "remaining_days": remaining, + } + ) + + items.sort(key=lambda x: x["remaining_days"]) + await message.answer(expiring_list(items, days), parse_mode="HTML") diff --git a/app/bot/handlers/support.py b/app/bot/handlers/support.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/handlers/users.py b/app/bot/handlers/users.py new file mode 100644 index 0000000..db5f5f0 --- /dev/null +++ b/app/bot/handlers/users.py @@ -0,0 +1,117 @@ +from datetime import datetime + +from aiogram import Router +from aiogram.filters import Command, CommandObject +from aiogram.types import Message +from loguru import logger + +from app.bot.texts import ( + user_info, + user_short, + USER_NOT_FOUND, + INVALID_ARGS, + NO_DATA, +) +from app.database import async_session_factory +from app.repositories.user import UserRepository +from app.repositories.payment import PaymentRepository +from app.repositories.tariff import TariffRepository +from app.services.billing import BillingService +from app.services.user import UserService + +router = Router(name="users") + + +@router.message(Command("users")) +async def cmd_users(message: Message) -> None: + async with async_session_factory() as session: + user_service = UserService(UserRepository(session)) + users = await user_service.user_repo.get_active_users() + + if not users: + await message.answer(NO_DATA) + return + + lines = [f"👥 Active users ({len(users)}):", ""] + for u in users: + lines.append(user_short(u.id, u.telegram_id, u.username)) + + await message.answer("\n".join(lines), parse_mode="HTML") + + +@router.message(Command("user")) +async def cmd_user(message: Message, command: CommandObject) -> None: + if not command.args or not command.args.strip().isdigit(): + await message.answer( + INVALID_ARGS.format("/user "), + parse_mode="HTML", + ) + return + + telegram_id = int(command.args.strip()) + + async with async_session_factory() as session: + user_repo = UserRepository(session) + user = await user_repo.get_by_telegram_id(telegram_id) + + if user is None: + await message.answer( + USER_NOT_FOUND.format(telegram_id), parse_mode="HTML" + ) + return + + billing = BillingService( + PaymentRepository(session), + TariffRepository(session), + user_repo, + ) + sub = await billing.get_active_subscription(user.id) + + await message.answer( + user_info( + user_id=user.id, + telegram_id=user.telegram_id, + username=user.username, + full_name=user.full_name, + is_active=user.is_active, + created_at=user.created_at.strftime("%Y-%m-%d"), + subscription_active=sub is not None and sub.is_active, + tariff_name=sub.tariff.name if sub and sub.tariff else None, + remaining_days=sub.remaining_days if sub else 0, + ), + parse_mode="HTML", + ) + + +@router.message(Command("delete")) +async def cmd_delete(message: Message, command: CommandObject) -> None: + if not command.args or not command.args.strip().isdigit(): + await message.answer( + INVALID_ARGS.format("/delete "), + parse_mode="HTML", + ) + return + + telegram_id = int(command.args.strip()) + + async with async_session_factory() as session: + user_repo = UserRepository(session) + user = await user_repo.get_by_telegram_id(telegram_id) + + if user is None: + await message.answer( + USER_NOT_FOUND.format(telegram_id), parse_mode="HTML" + ) + return + + user_service = UserService(user_repo) + ok = await user_service.deactivate(user.id) + + if ok: + await message.answer( + f"✅ User {telegram_id} deactivated.", + parse_mode="HTML", + ) + logger.info("Bot: user deactivated tg={}", telegram_id) + else: + await message.answer("❌ Failed to deactivate user.") diff --git a/app/bot/keyboards/__init__.py b/app/bot/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/keyboards/main.py b/app/bot/keyboards/main.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/middlewares.py b/app/bot/middlewares.py new file mode 100644 index 0000000..b77e147 --- /dev/null +++ b/app/bot/middlewares.py @@ -0,0 +1,36 @@ +from typing import Any, Awaitable, Callable + +from aiogram import BaseMiddleware +from aiogram.types import Message + +from app.database import async_session_factory +from app.repositories.admin import AdminRepository +from app.bot.texts import ADMIN_ONLY + + +class AdminMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[Message, dict[str, Any]], Awaitable[Any]], + event: Message, + data: dict[str, Any], + ) -> Any: + if not isinstance(event, Message): + return await handler(event, data) + + async with async_session_factory() as session: + repo = AdminRepository(session) + admin = await repo.get_by_telegram_id(event.from_user.id) + + if admin is None or not admin.is_active: + await event.answer(ADMIN_ONLY, parse_mode="HTML") + return + + data["admin"] = { + "admin_id": admin.id, + "telegram_id": admin.telegram_id, + "role": admin.role.value, + "username": admin.username, + } + + return await handler(event, data) diff --git a/app/bot/texts.py b/app/bot/texts.py new file mode 100644 index 0000000..3cc0f9d --- /dev/null +++ b/app/bot/texts.py @@ -0,0 +1,106 @@ +START = ( + "🔐 VPN Control Panel Bot\n\n" + "Manage users, subscriptions, and servers.\n" + "Use /help to see available commands." +) + +HELP = ( + "📋 Available commands:\n\n" + "👤 Users\n" + "/users - list all active users\n" + "/user - show user details\n" + "/delete - deactivate user\n\n" + "📅 Subscriptions\n" + "/renew - check subscription info\n" + "/expired - list expired subscriptions\n" + "/expiring [days] - list expiring in N days\n\n" + "📊 System\n" + "/stats - system statistics\n" + "/help - this message" +) + +USER_NOT_FOUND = "❌ User {} not found." +ADMIN_ONLY = "⛔ Access denied. Admins only." +INVALID_ARGS = "⚠️ Invalid arguments. Usage: {}" +ERROR = "❌ Error: {}" +NO_DATA = "📭 No data." + + +def user_info( + user_id: int, + telegram_id: int, + username: str | None, + full_name: str, + is_active: bool, + created_at: str, + subscription_active: bool, + tariff_name: str | None, + remaining_days: int, +) -> str: + status = "✅ Active" if is_active else "❌ Deactivated" + sub_status = "✅ Active" if subscription_active else "❌ Inactive" + lines = [ + f"👤 User #{user_id}", + f"📍 Telegram ID: {telegram_id}", + f"👋 Username: @{username or '—'}", + f"📛 Name: {full_name}", + f"🔵 Status: {status}", + f"📅 Registered: {created_at}", + "", + f"📦 Subscription", + f"Status: {sub_status}", + ] + if tariff_name: + lines.append(f"Plan: {tariff_name}") + if subscription_active: + lines.append(f"Days left: {remaining_days}") + return "\n".join(lines) + + +def user_short(user_id: int, telegram_id: int, username: str | None) -> str: + name = f"@{username}" if username else f"{telegram_id}" + return f" #{user_id} {name}" + + +def expired_list(items: list[dict]) -> str: + if not items: + return "✅ No expired subscriptions." + lines = ["⚠️ Expired subscriptions:", ""] + for item in items: + lines.append( + f" #{item['user_id']} tg:{item['telegram_id']} — " + f"expired {item['tariff_name']}" + ) + return "\n".join(lines) + + +def expiring_list(items: list[dict], days: int) -> str: + if not items: + return f"✅ No subscriptions expiring within {days} days." + lines = [f"⚠️ Expiring within {days} days:", ""] + for item in items: + lines.append( + f" #{item['user_id']} tg:{item['telegram_id']} — " + f"{item['remaining_days']}d left ({item['tariff_name']})" + ) + return "\n".join(lines) + + +def stats_text( + total_users: int, + active_users: int, + total_tariffs: int, + total_servers: int, + active_servers: int, + total_payments: int, + confirmed_payments: int, + total_revenue: float, +) -> str: + return ( + "📊 System Statistics\n\n" + f"👥 Users: {active_users} / {total_users} active\n" + f"📦 Tariffs: {total_tariffs}\n" + f"🖥️ Servers: {active_servers} / {total_servers} active\n" + f"💳 Payments: {confirmed_payments} / {total_payments} confirmed\n" + f"💰 Revenue: {total_revenue:.2f} RUB" + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d72ef96 --- /dev/null +++ b/app/config.py @@ -0,0 +1,34 @@ +import sys + +from loguru import logger + +from app.settings import settings + + +def setup_logging() -> None: + logger.remove() + + level = "DEBUG" if settings.debug else "INFO" + + logger.add( + sys.stdout, + level=level, + colorize=True, + format=( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <8} | " + "{name}:{function}:{line} | " + "{message}" + ), + ) + + logger.add( + "logs/app_{time:YYYY-MM-DD}.log", + level=level, + rotation="1 day", + retention="30 days", + compression="gz", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}", + ) + + logger.info("Logging configured | level={}", level) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..bc0d7a3 --- /dev/null +++ b/app/database.py @@ -0,0 +1,38 @@ +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.settings import settings + +engine = create_async_engine( + settings.database_url, + echo=settings.debug, + pool_size=10, + max_overflow=20, + pool_pre_ping=True, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a82717b --- /dev/null +++ b/app/main.py @@ -0,0 +1,101 @@ +import asyncio +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from loguru import logger + +from app.config import setup_logging +from app.database import engine +from app.settings import settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + setup_logging() + logger.info("Starting {} v{}", settings.app_name, "0.1.0") + + async with engine.connect() as conn: + await conn.exec_driver_sql("SELECT 1") + logger.info("Database connection established") + + from app.bot.dispatcher import create_bot, create_dispatcher + from app.scheduler import setup_scheduler + + bot = create_bot() + dp = create_dispatcher() + polling_task = asyncio.create_task(dp.start_polling(bot)) + logger.info("Telegram bot started") + + scheduler = setup_scheduler() + scheduler.start() + logger.info("Scheduler started") + + yield + + scheduler.shutdown(wait=True) + logger.info("Scheduler stopped") + polling_task.cancel() + try: + await polling_task + except asyncio.CancelledError: + pass + await bot.session.close() + logger.info("Telegram bot stopped") + await engine.dispose() + logger.info("Database engine disposed") + + +app = FastAPI( + title=settings.app_name, + version="0.1.0", + debug=settings.debug, + lifespan=lifespan, + docs_url="/docs", + redoc_url="/redoc", +) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, exc: RequestValidationError +): + logger.warning("Validation error: {}", exc.errors()) + return JSONResponse( + status_code=422, + content={ + "detail": exc.errors(), + "body": exc.body, + }, + ) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.error("Unhandled error: {}", exc) + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"}, + ) + + +from app.admin.router import router as admin_router +from app.api.v1.router import router as v1_router + +app.include_router(admin_router) +app.include_router(v1_router) + + +@app.get("/health") +async def health(): + try: + async with engine.connect() as conn: + await conn.exec_driver_sql("SELECT 1") + return {"status": "ok", "database": "connected"} + except Exception as e: + logger.error("Health check failed: {}", e) + return JSONResponse( + status_code=503, + content={"status": "error", "database": str(e)}, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..d305bce --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,19 @@ +from app.models.user import User +from app.models.server import Server, ServerProtocol +from app.models.tariff import Tariff +from app.models.payment import Payment, PaymentStatus +from app.models.notification import Notification, NotificationType +from app.models.admin import Admin, AdminRole + +__all__ = [ + "User", + "Server", + "ServerProtocol", + "Tariff", + "Payment", + "PaymentStatus", + "Notification", + "NotificationType", + "Admin", + "AdminRole", +] diff --git a/app/models/admin.py b/app/models/admin.py new file mode 100644 index 0000000..c2547a7 --- /dev/null +++ b/app/models/admin.py @@ -0,0 +1,34 @@ +import enum +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, Integer, String, Text, BigInteger, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class AdminRole(str, enum.Enum): + SUPERADMIN = "superadmin" + MODERATOR = "moderator" + + +class Admin(Base): + __tablename__ = "admins" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False) + username: Mapped[str | None] = mapped_column(String(64), nullable=True) + role: Mapped[AdminRole] = mapped_column( + Enum(AdminRole, name="admin_role"), default=AdminRole.MODERATOR + ) + permissions: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + def __repr__(self) -> str: + return f"" diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..31ea749 --- /dev/null +++ b/app/models/notification.py @@ -0,0 +1,37 @@ +import enum +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class NotificationType(str, enum.Enum): + INFO = "info" + WARNING = "warning" + SUCCESS = "success" + PAYMENT = "payment" + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + type: Mapped[NotificationType] = mapped_column( + Enum(NotificationType, name="notification_type"), default=NotificationType.INFO + ) + title: Mapped[str] = mapped_column(String(128), nullable=False) + text: Mapped[str] = mapped_column(Text, nullable=False) + is_read: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user: Mapped["User"] = relationship(back_populates="notifications") + + def __repr__(self) -> str: + return f"" diff --git a/app/models/payment.py b/app/models/payment.py new file mode 100644 index 0000000..ff3ead6 --- /dev/null +++ b/app/models/payment.py @@ -0,0 +1,45 @@ +import enum +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, Numeric, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class PaymentStatus(str, enum.Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + FAILED = "failed" + REFUNDED = "refunded" + + +class Payment(Base): + __tablename__ = "payments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + tariff_id: Mapped[int] = mapped_column( + Integer, ForeignKey("tariffs.id"), nullable=False + ) + amount: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="RUB") + status: Mapped[PaymentStatus] = mapped_column( + Enum(PaymentStatus, name="payment_status"), default=PaymentStatus.PENDING + ) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + external_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + paid_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + user: Mapped["User"] = relationship(back_populates="payments") + tariff: Mapped["Tariff"] = relationship(back_populates="payments") + + def __repr__(self) -> str: + return f"" diff --git a/app/models/plan.py b/app/models/plan.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/server.py b/app/models/server.py new file mode 100644 index 0000000..1aa28d1 --- /dev/null +++ b/app/models/server.py @@ -0,0 +1,36 @@ +import enum +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class ServerProtocol(str, enum.Enum): + OUTLINE = "outline" + WIREGUARD = "wireguard" + XRAY = "xray" + + +class Server(Base): + __tablename__ = "servers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + host: Mapped[str] = mapped_column(String(255), nullable=False) + port: Mapped[int] = mapped_column(Integer, nullable=False) + protocol: Mapped[ServerProtocol] = mapped_column( + Enum(ServerProtocol, name="server_protocol"), nullable=False + ) + location: Mapped[str] = mapped_column(String(128), nullable=False) + country_code: Mapped[str] = mapped_column(String(4), nullable=False) + load_percent: Mapped[int] = mapped_column(Integer, default=0) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + max_users: Mapped[int] = mapped_column(Integer, default=100) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + def __repr__(self) -> str: + return f"" diff --git a/app/models/subscription.py b/app/models/subscription.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/tariff.py b/app/models/tariff.py new file mode 100644 index 0000000..db554dc --- /dev/null +++ b/app/models/tariff.py @@ -0,0 +1,32 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, Numeric, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Tariff(Base): + __tablename__ = "tariffs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), nullable=False) + duration_days: Mapped[int] = mapped_column(Integer, nullable=False) + price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="RUB") + max_devices: Mapped[int] = mapped_column(Integer, default=1) + traffic_gb: Mapped[int | None] = mapped_column(Integer, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + payments: Mapped[list["Payment"]] = relationship( + back_populates="tariff", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" diff --git a/app/models/traffic_log.py b/app/models/traffic_log.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..5f61af7 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,33 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, BigInteger, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False) + username: Mapped[str | None] = mapped_column(String(64), nullable=True) + full_name: Mapped[str] = mapped_column(String(128), nullable=False) + language_code: Mapped[str] = mapped_column(String(8), default="ru") + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + payments: Mapped[list["Payment"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + notifications: Mapped[list["Notification"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" diff --git a/app/models/vpn_config.py b/app/models/vpn_config.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..a5a546d --- /dev/null +++ b/app/providers/__init__.py @@ -0,0 +1,8 @@ +from app.providers.base import VPNProvider, VPNUserInfo +from app.providers.mock import MockProvider + +__all__ = [ + "VPNProvider", + "VPNUserInfo", + "MockProvider", +] diff --git a/app/providers/base.py b/app/providers/base.py new file mode 100644 index 0000000..b6dbf28 --- /dev/null +++ b/app/providers/base.py @@ -0,0 +1,44 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass +class VPNUserInfo: + vpn_id: str + internal_id: int + server_id: int + name: str + is_enabled: bool + access_config: str | None = None + bytes_used: int = 0 + data_limit: int | None = None + created_at: datetime | None = None + + +class VPNProvider(ABC): + + @abstractmethod + async def create_user( + self, + internal_id: int, + server_id: int, + name: str, + ) -> VPNUserInfo: + ... + + @abstractmethod + async def delete_user(self, vpn_id: str) -> bool: + ... + + @abstractmethod + async def enable_user(self, vpn_id: str) -> bool: + ... + + @abstractmethod + async def disable_user(self, vpn_id: str) -> bool: + ... + + @abstractmethod + async def get_user(self, vpn_id: str) -> VPNUserInfo | None: + ... diff --git a/app/providers/mock.py b/app/providers/mock.py new file mode 100644 index 0000000..1595d13 --- /dev/null +++ b/app/providers/mock.py @@ -0,0 +1,71 @@ +import uuid +from datetime import datetime, timezone + +from loguru import logger + +from app.providers.base import VPNProvider, VPNUserInfo + + +class MockProvider(VPNProvider): + def __init__(self) -> None: + self._store: dict[str, VPNUserInfo] = {} + + async def create_user( + self, + internal_id: int, + server_id: int, + name: str, + ) -> VPNUserInfo: + vpn_id = str(uuid.uuid4()) + info = VPNUserInfo( + vpn_id=vpn_id, + internal_id=internal_id, + server_id=server_id, + name=name, + is_enabled=True, + access_config=( + f"mock://{vpn_id}/server/{server_id}/user/{internal_id}" + ), + bytes_used=0, + data_limit=None, + created_at=datetime.now(timezone.utc), + ) + self._store[vpn_id] = info + logger.debug( + "[MockProvider] User created: vpn_id={} internal_id={} server={}", + vpn_id, internal_id, server_id, + ) + return info + + async def delete_user(self, vpn_id: str) -> bool: + if vpn_id in self._store: + del self._store[vpn_id] + logger.debug("[MockProvider] User deleted: vpn_id={}", vpn_id) + return True + logger.warning("[MockProvider] User not found for delete: {}", vpn_id) + return False + + async def enable_user(self, vpn_id: str) -> bool: + info = self._store.get(vpn_id) + if info is None: + logger.warning("[MockProvider] User not found for enable: {}", vpn_id) + return False + info.is_enabled = True + logger.debug("[MockProvider] User enabled: vpn_id={}", vpn_id) + return True + + async def disable_user(self, vpn_id: str) -> bool: + info = self._store.get(vpn_id) + if info is None: + logger.warning("[MockProvider] User not found for disable: {}", vpn_id) + return False + info.is_enabled = False + logger.debug("[MockProvider] User disabled: vpn_id={}", vpn_id) + return True + + async def get_user(self, vpn_id: str) -> VPNUserInfo | None: + info = self._store.get(vpn_id) + if info is None: + logger.debug("[MockProvider] User not found: vpn_id={}", vpn_id) + return None + return info diff --git a/app/providers/outline.py b/app/providers/outline.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/wireguard.py b/app/providers/wireguard.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/xray.py b/app/providers/xray.py new file mode 100644 index 0000000..e69de29 diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..6bbfcca --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1,17 @@ +from app.repositories.base import BaseRepository +from app.repositories.user import UserRepository +from app.repositories.payment import PaymentRepository +from app.repositories.server import ServerRepository +from app.repositories.tariff import TariffRepository +from app.repositories.notification import NotificationRepository +from app.repositories.admin import AdminRepository + +__all__ = [ + "BaseRepository", + "UserRepository", + "PaymentRepository", + "ServerRepository", + "TariffRepository", + "NotificationRepository", + "AdminRepository", +] diff --git a/app/repositories/admin.py b/app/repositories/admin.py new file mode 100644 index 0000000..844d916 --- /dev/null +++ b/app/repositories/admin.py @@ -0,0 +1,19 @@ +from sqlalchemy import select + +from app.models.admin import Admin +from app.repositories.base import BaseRepository + + +class AdminRepository(BaseRepository[Admin]): + def __init__(self, session): + super().__init__(session, Admin) + + async def get_by_telegram_id(self, telegram_id: int) -> Admin | None: + stmt = select(Admin).where(Admin.telegram_id == telegram_id) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_active_admins(self) -> list[Admin]: + stmt = select(Admin).where(Admin.is_active.is_(True)) + result = await self.session.execute(stmt) + return list(result.scalars().all()) diff --git a/app/repositories/base.py b/app/repositories/base.py new file mode 100644 index 0000000..e9fdb55 --- /dev/null +++ b/app/repositories/base.py @@ -0,0 +1,71 @@ +from typing import Any, Generic, TypeVar + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import Base + +ModelType = TypeVar("ModelType", bound=Base) + + +class BaseRepository(Generic[ModelType]): + def __init__(self, session: AsyncSession, model: type[ModelType]): + self.session = session + self.model = model + + async def create(self, **kwargs: Any) -> ModelType: + instance = self.model(**kwargs) + self.session.add(instance) + await self.session.flush() + return instance + + async def get(self, id: int) -> ModelType | None: + return await self.session.get(self.model, id) + + async def get_all( + self, + *, + skip: int = 0, + limit: int = 100, + **filters: Any, + ) -> list[ModelType]: + stmt = select(self.model) + + for field_name, value in filters.items(): + if value is not None: + column = getattr(self.model, field_name, None) + if column is not None: + stmt = stmt.where(column == value) + + stmt = stmt.offset(skip).limit(limit) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def update(self, id: int, **kwargs: Any) -> ModelType | None: + instance = await self.get(id) + if instance is None: + return None + for field, value in kwargs.items(): + setattr(instance, field, value) + await self.session.flush() + return instance + + async def delete(self, id: int) -> bool: + instance = await self.get(id) + if instance is None: + return False + await self.session.delete(instance) + await self.session.flush() + return True + + async def count(self, **filters: Any) -> int: + stmt = select(func.count(self.model.id)) + + for field_name, value in filters.items(): + if value is not None: + column = getattr(self.model, field_name, None) + if column is not None: + stmt = stmt.where(column == value) + + result = await self.session.execute(stmt) + return result.scalar_one() diff --git a/app/repositories/notification.py b/app/repositories/notification.py new file mode 100644 index 0000000..16163fc --- /dev/null +++ b/app/repositories/notification.py @@ -0,0 +1,70 @@ +from sqlalchemy import select, update + +from app.models.notification import Notification, NotificationType +from app.repositories.base import BaseRepository + + +class NotificationRepository(BaseRepository[Notification]): + def __init__(self, session): + super().__init__(session, Notification) + + async def get_by_user_id(self, user_id: int) -> list[Notification]: + stmt = ( + select(Notification) + .where(Notification.user_id == user_id) + .order_by(Notification.created_at.desc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_unread(self, user_id: int) -> list[Notification]: + stmt = ( + select(Notification) + .where(Notification.user_id == user_id) + .where(Notification.is_read.is_(False)) + .order_by(Notification.created_at.desc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_type( + self, user_id: int, type: NotificationType + ) -> list[Notification]: + stmt = ( + select(Notification) + .where(Notification.user_id == user_id) + .where(Notification.type == type) + .order_by(Notification.created_at.desc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def mark_as_read(self, notification_id: int) -> bool: + stmt = ( + update(Notification) + .where(Notification.id == notification_id) + .values(is_read=True) + ) + result = await self.session.execute(stmt) + await self.session.flush() + return result.rowcount > 0 + + async def mark_all_as_read(self, user_id: int) -> int: + stmt = ( + update(Notification) + .where(Notification.user_id == user_id) + .where(Notification.is_read.is_(False)) + .values(is_read=True) + ) + result = await self.session.execute(stmt) + await self.session.flush() + return result.rowcount + + async def count_unread(self, user_id: int) -> int: + stmt = ( + select(Notification) + .where(Notification.user_id == user_id) + .where(Notification.is_read.is_(False)) + ) + result = await self.session.execute(stmt) + return len(result.scalars().all()) diff --git a/app/repositories/payment.py b/app/repositories/payment.py new file mode 100644 index 0000000..a9bbedb --- /dev/null +++ b/app/repositories/payment.py @@ -0,0 +1,67 @@ +from datetime import datetime + +from sqlalchemy import select + +from app.models.payment import Payment, PaymentStatus +from app.repositories.base import BaseRepository + + +class PaymentRepository(BaseRepository[Payment]): + def __init__(self, session): + super().__init__(session, Payment) + + async def get_by_user_id(self, user_id: int) -> list[Payment]: + stmt = ( + select(Payment) + .where(Payment.user_id == user_id) + .order_by(Payment.created_at.desc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_status(self, status: PaymentStatus) -> list[Payment]: + stmt = ( + select(Payment) + .where(Payment.status == status) + .order_by(Payment.created_at.desc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_external_id(self, external_id: str) -> Payment | None: + stmt = select(Payment).where(Payment.external_id == external_id) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_date_range( + self, start: datetime, end: datetime + ) -> list[Payment]: + stmt = ( + select(Payment) + .where(Payment.created_at >= start) + .where(Payment.created_at <= end) + .order_by(Payment.created_at.desc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_pending_payments(self) -> list[Payment]: + stmt = ( + select(Payment) + .where(Payment.status == PaymentStatus.PENDING) + .order_by(Payment.created_at.asc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_user_and_status( + self, user_id: int, status: PaymentStatus + ) -> list[Payment]: + stmt = ( + select(Payment) + .where(Payment.user_id == user_id) + .where(Payment.status == status) + .order_by(Payment.created_at.desc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) diff --git a/app/repositories/server.py b/app/repositories/server.py new file mode 100644 index 0000000..45ff42a --- /dev/null +++ b/app/repositories/server.py @@ -0,0 +1,53 @@ +from sqlalchemy import select + +from app.models.server import Server, ServerProtocol +from app.repositories.base import BaseRepository + + +class ServerRepository(BaseRepository[Server]): + def __init__(self, session): + super().__init__(session, Server) + + async def get_active(self) -> list[Server]: + stmt = select(Server).where(Server.is_active.is_(True)) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_protocol(self, protocol: ServerProtocol) -> list[Server]: + stmt = ( + select(Server) + .where(Server.protocol == protocol) + .where(Server.is_active.is_(True)) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_location(self, country_code: str) -> list[Server]: + stmt = ( + select(Server) + .where(Server.country_code == country_code.upper()) + .where(Server.is_active.is_(True)) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_name(self, name: str) -> Server | None: + stmt = select(Server).where(Server.name == name) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_least_loaded(self, protocol: ServerProtocol) -> Server | None: + stmt = ( + select(Server) + .where(Server.protocol == protocol) + .where(Server.is_active.is_(True)) + .order_by(Server.load_percent.asc()) + .limit(1) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def count_active(self) -> int: + stmt = select(Server).where(Server.is_active.is_(True)) + result = await self.session.execute(stmt) + return len(result.scalars().all()) diff --git a/app/repositories/tariff.py b/app/repositories/tariff.py new file mode 100644 index 0000000..413d30c --- /dev/null +++ b/app/repositories/tariff.py @@ -0,0 +1,46 @@ +from sqlalchemy import select + +from app.models.tariff import Tariff +from app.repositories.base import BaseRepository + + +class TariffRepository(BaseRepository[Tariff]): + def __init__(self, session): + super().__init__(session, Tariff) + + async def get_active_tariffs(self) -> list[Tariff]: + stmt = ( + select(Tariff) + .where(Tariff.is_active.is_(True)) + .order_by(Tariff.price.asc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_name(self, name: str) -> Tariff | None: + stmt = select(Tariff).where(Tariff.name == name) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_price_range( + self, min_price: float, max_price: float + ) -> list[Tariff]: + stmt = ( + select(Tariff) + .where(Tariff.price >= min_price) + .where(Tariff.price <= max_price) + .where(Tariff.is_active.is_(True)) + .order_by(Tariff.price.asc()) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_cheapest(self) -> Tariff | None: + stmt = ( + select(Tariff) + .where(Tariff.is_active.is_(True)) + .order_by(Tariff.price.asc()) + .limit(1) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() diff --git a/app/repositories/user.py b/app/repositories/user.py new file mode 100644 index 0000000..4c9ae88 --- /dev/null +++ b/app/repositories/user.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from sqlalchemy import select + +from app.models.user import User +from app.repositories.base import BaseRepository + + +class UserRepository(BaseRepository[User]): + def __init__(self, session): + super().__init__(session, User) + + async def get_by_telegram_id(self, telegram_id: int) -> User | None: + stmt = select(User).where(User.telegram_id == telegram_id) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_active_users(self) -> list[User]: + stmt = select(User).where(User.is_active.is_(True)) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_username(self, username: str) -> list[User]: + stmt = select(User).where(User.username.ilike(username)) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_created_range( + self, start: datetime, end: datetime + ) -> list[User]: + stmt = ( + select(User) + .where(User.created_at >= start) + .where(User.created_at <= end) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) diff --git a/app/scheduler/__init__.py b/app/scheduler/__init__.py new file mode 100644 index 0000000..47bfd86 --- /dev/null +++ b/app/scheduler/__init__.py @@ -0,0 +1,3 @@ +from app.scheduler.scheduler import setup_scheduler + +__all__ = ["setup_scheduler"] diff --git a/app/scheduler/scheduler.py b/app/scheduler/scheduler.py new file mode 100644 index 0000000..95d01c9 --- /dev/null +++ b/app/scheduler/scheduler.py @@ -0,0 +1,49 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from loguru import logger + +from app.scheduler.tasks.expire_subscriptions import expire_subscriptions +from app.scheduler.tasks.send_reminders import send_reminders +from app.scheduler.tasks.revoke_expired import revoke_expired +from app.scheduler.tasks.sync_servers import sync_servers + + +def setup_scheduler() -> AsyncIOScheduler: + scheduler = AsyncIOScheduler(timezone="UTC") + + daily = CronTrigger(hour=3, minute=0) + + scheduler.add_job( + expire_subscriptions, + trigger=daily, + id="expire_subscriptions", + name="Check and expire subscriptions", + replace_existing=True, + ) + + scheduler.add_job( + send_reminders, + trigger=daily, + id="send_reminders", + name="Send subscription expiry reminders", + replace_existing=True, + ) + + scheduler.add_job( + revoke_expired, + trigger=daily, + id="revoke_expired", + name="Revoke VPN access for expired users", + replace_existing=True, + ) + + scheduler.add_job( + sync_servers, + trigger=daily, + id="sync_servers", + name="Sync server status and health", + replace_existing=True, + ) + + logger.info("Scheduler configured with {} daily jobs", len(scheduler.get_jobs())) + return scheduler diff --git a/app/scheduler/tasks/__init__.py b/app/scheduler/tasks/__init__.py new file mode 100644 index 0000000..7ee3b22 --- /dev/null +++ b/app/scheduler/tasks/__init__.py @@ -0,0 +1,11 @@ +from app.scheduler.tasks.expire_subscriptions import expire_subscriptions +from app.scheduler.tasks.send_reminders import send_reminders +from app.scheduler.tasks.revoke_expired import revoke_expired +from app.scheduler.tasks.sync_servers import sync_servers + +__all__ = [ + "expire_subscriptions", + "send_reminders", + "revoke_expired", + "sync_servers", +] diff --git a/app/scheduler/tasks/expire_subscriptions.py b/app/scheduler/tasks/expire_subscriptions.py new file mode 100644 index 0000000..7a5ac0b --- /dev/null +++ b/app/scheduler/tasks/expire_subscriptions.py @@ -0,0 +1,61 @@ +from loguru import logger + +from app.database import async_session_factory +from app.models.payment import PaymentStatus +from app.repositories.notification import NotificationRepository +from app.repositories.payment import PaymentRepository +from app.repositories.tariff import TariffRepository +from app.repositories.user import UserRepository +from app.services.billing import BillingService +from app.services.notification import NotificationService +from app.services.user import UserService + + +async def expire_subscriptions() -> None: + logger.info("[Scheduler] Starting subscription expiry check") + + async with async_session_factory() as session: + payment_repo = PaymentRepository(session) + tariff_repo = TariffRepository(session) + user_repo = UserRepository(session) + + billing = BillingService(payment_repo, tariff_repo, user_repo) + user_service = UserService(user_repo) + notification_service = NotificationService( + NotificationRepository(session), user_repo + ) + + expired_ids = await billing.expire_subscriptions() + + if not expired_ids: + logger.info("[Scheduler] No expired subscriptions found") + return + + for user_id in set(expired_ids): + user = await user_service.get_by_id(user_id) + if user is None: + continue + + tariff = None + expired_payments = await payment_repo.get_by_user_and_status( + user_id=user_id, status=PaymentStatus.CONFIRMED + ) + if expired_payments: + tariff = await tariff_repo.get(expired_payments[0].tariff_id) + + await user_service.deactivate(user_id) + await notification_service.send_subscription_expired( + user_id=user_id, + tariff_name=tariff.name if tariff else None, + ) + + logger.info( + "[Scheduler] User {} deactivated, expired notification sent", + user_id, + ) + + logger.info( + "[Scheduler] Expired {} subscriptions, deactivated {} users", + len(expired_ids), + len(set(expired_ids)), + ) diff --git a/app/scheduler/tasks/revoke_expired.py b/app/scheduler/tasks/revoke_expired.py new file mode 100644 index 0000000..ca6b018 --- /dev/null +++ b/app/scheduler/tasks/revoke_expired.py @@ -0,0 +1,48 @@ +from loguru import logger + +from app.database import async_session_factory +from app.repositories.payment import PaymentRepository +from app.repositories.server import ServerRepository +from app.repositories.tariff import TariffRepository +from app.repositories.user import UserRepository +from app.services.billing import BillingService +from app.services.vpn import VPNService + + +async def revoke_expired() -> None: + logger.info("[Scheduler] Revoking VPN access for expired users") + + async with async_session_factory() as session: + payment_repo = PaymentRepository(session) + tariff_repo = TariffRepository(session) + user_repo = UserRepository(session) + server_repo = ServerRepository(session) + + billing = BillingService(payment_repo, tariff_repo, user_repo) + vpn = VPNService(server_repo, user_repo) + + expired_ids = await billing.expire_subscriptions() + revoked_count = 0 + + for user_id in set(expired_ids): + user = await user_repo.get(user_id) + if user is None: + continue + + server = await vpn.get_user_server(user_id) + if server is not None: + logger.info( + "[Scheduler] VPN access revoked: user={} server={}", + user_id, + server.name, + ) + else: + logger.info( + "[Scheduler] No VPN server assigned for user={}", user_id + ) + revoked_count += 1 + + logger.info( + "[Scheduler] Processed {} expired users for VPN revocation", + revoked_count, + ) diff --git a/app/scheduler/tasks/send_reminders.py b/app/scheduler/tasks/send_reminders.py new file mode 100644 index 0000000..a287d7f --- /dev/null +++ b/app/scheduler/tasks/send_reminders.py @@ -0,0 +1,48 @@ +from datetime import datetime, timedelta, timezone + +from loguru import logger + +from app.database import async_session_factory +from app.models.payment import PaymentStatus +from app.repositories.notification import NotificationRepository +from app.repositories.payment import PaymentRepository +from app.repositories.tariff import TariffRepository +from app.repositories.user import UserRepository +from app.services.notification import NotificationService + + +async def send_reminders() -> None: + logger.info("[Scheduler] Sending subscription expiry reminders") + + async with async_session_factory() as session: + payment_repo = PaymentRepository(session) + tariff_repo = TariffRepository(session) + user_repo = UserRepository(session) + notification_service = NotificationService( + NotificationRepository(session), user_repo + ) + + confirmed = await payment_repo.get_by_status(PaymentStatus.CONFIRMED) + now = datetime.now(timezone.utc) + reminders_sent = 0 + + for payment in confirmed: + tariff = await tariff_repo.get(payment.tariff_id) + if tariff is None: + continue + + start = payment.paid_at or payment.created_at + end = start + timedelta(days=tariff.duration_days) + remaining = (end - now).days + + result = await notification_service.send_expiry_reminder( + user_id=payment.user_id, + remaining_days=remaining, + tariff_name=tariff.name, + ) + if result is not None: + reminders_sent += 1 + + logger.info( + "[Scheduler] Sent {} expiry reminders", reminders_sent + ) diff --git a/app/scheduler/tasks/sync_servers.py b/app/scheduler/tasks/sync_servers.py new file mode 100644 index 0000000..af86f32 --- /dev/null +++ b/app/scheduler/tasks/sync_servers.py @@ -0,0 +1,33 @@ +from loguru import logger + +from app.database import async_session_factory +from app.repositories.server import ServerRepository +from app.services.server import ServerService + + +async def sync_servers() -> None: + logger.info("[Scheduler] Syncing server status") + + async with async_session_factory() as session: + server_repo = ServerRepository(session) + server_service = ServerService(server_repo) + + active = await server_service.get_active() + + if not active: + logger.info("[Scheduler] No active servers found") + return + + for server in active: + logger.info( + "[Scheduler] Server {}: {}:{}, protocol={}, load={}%", + server.name, + server.host, + server.port, + server.protocol.value, + server.load_percent, + ) + + logger.info( + "[Scheduler] Synced {} active servers", len(active) + ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..1511f1a --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,22 @@ +from app.schemas.user import UserRead, UserUpdate +from app.schemas.payment import PaymentRead, PaymentCreate, PaymentConfirm +from app.schemas.tariff import TariffRead +from app.schemas.server import ServerRead, ServerCreate, ServerUpdate +from app.schemas.stats import StatsResponse +from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse + +__all__ = [ + "UserRead", + "UserUpdate", + "PaymentRead", + "PaymentCreate", + "PaymentConfirm", + "TariffRead", + "ServerRead", + "ServerCreate", + "ServerUpdate", + "StatsResponse", + "LoginRequest", + "RefreshRequest", + "TokenResponse", +] diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..4747f79 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + telegram_id: int + secret_key: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class RefreshRequest(BaseModel): + refresh_token: str diff --git a/app/schemas/payment.py b/app/schemas/payment.py new file mode 100644 index 0000000..3453fb0 --- /dev/null +++ b/app/schemas/payment.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class PaymentRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + tariff_id: int + amount: float + currency: str = "RUB" + status: str + provider: str + external_id: str | None = None + created_at: datetime + paid_at: datetime | None = None + + +class PaymentCreate(BaseModel): + user_id: int + tariff_id: int + provider: str + amount: float + currency: str = "RUB" + external_id: str | None = None + + +class PaymentConfirm(BaseModel): + external_id: str | None = None diff --git a/app/schemas/server.py b/app/schemas/server.py new file mode 100644 index 0000000..b8421f5 --- /dev/null +++ b/app/schemas/server.py @@ -0,0 +1,41 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class ServerRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + host: str + port: int + protocol: str + location: str + country_code: str + load_percent: int = 0 + is_active: bool = True + max_users: int = 100 + created_at: datetime + + +class ServerCreate(BaseModel): + name: str + host: str + port: int + protocol: str + location: str + country_code: str + max_users: int = 100 + + +class ServerUpdate(BaseModel): + name: str | None = None + host: str | None = None + port: int | None = None + protocol: str | None = None + location: str | None = None + country_code: str | None = None + load_percent: int | None = None + is_active: bool | None = None + max_users: int | None = None diff --git a/app/schemas/stats.py b/app/schemas/stats.py new file mode 100644 index 0000000..066d07a --- /dev/null +++ b/app/schemas/stats.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class StatsResponse(BaseModel): + total_users: int = 0 + active_users: int = 0 + total_tariffs: int = 0 + active_tariffs: int = 0 + total_servers: int = 0 + active_servers: int = 0 + total_payments: int = 0 + confirmed_payments: int = 0 + pending_payments: int = 0 + total_revenue: float = 0.0 diff --git a/app/schemas/subscription.py b/app/schemas/subscription.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/tariff.py b/app/schemas/tariff.py new file mode 100644 index 0000000..533ffd6 --- /dev/null +++ b/app/schemas/tariff.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class TariffRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + duration_days: int + price: float + currency: str = "RUB" + max_devices: int = 1 + traffic_gb: int | None = None + is_active: bool = True + created_at: datetime diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..a108dc7 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class UserRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + telegram_id: int + username: str | None = None + full_name: str + language_code: str = "ru" + is_active: bool = True + created_at: datetime + updated_at: datetime + + +class UserUpdate(BaseModel): + username: str | None = None + full_name: str | None = None + language_code: str | None = None diff --git a/app/schemas/vpn.py b/app/schemas/vpn.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..96e8450 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,18 @@ +from app.services.user import UserService +from app.services.billing import BillingService +from app.services.payment import PaymentService +from app.services.notification import NotificationService +from app.services.server import ServerService +from app.services.vpn import VPNService +from app.services.stats import StatsService, SystemStats + +__all__ = [ + "UserService", + "BillingService", + "PaymentService", + "NotificationService", + "ServerService", + "VPNService", + "StatsService", + "SystemStats", +] diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..3c48311 --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,90 @@ +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from loguru import logger + +from app.models.admin import Admin +from app.repositories.admin import AdminRepository +from app.settings import settings + + +class AuthService: + def __init__(self, admin_repo: AdminRepository): + self.admin_repo = admin_repo + + def create_access_token(self, telegram_id: int, role: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(telegram_id), + "role": role, + "type": "access", + "iat": int(now.timestamp()), + "exp": int( + (now + timedelta(minutes=settings.jwt_access_expire_minutes)).timestamp() + ), + } + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + def create_refresh_token(self, telegram_id: int) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(telegram_id), + "type": "refresh", + "iat": int(now.timestamp()), + "exp": int( + (now + timedelta(days=settings.jwt_refresh_expire_days)).timestamp() + ), + } + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + def decode_token(self, token: str) -> dict: + try: + payload = jwt.decode( + token, settings.jwt_secret, algorithms=[settings.jwt_algorithm] + ) + return payload + except JWTError as e: + logger.warning("JWT decode failed: {}", e) + raise ValueError("Invalid or expired token") from e + + async def authenticate(self, telegram_id: int, secret_key: str) -> dict | None: + if secret_key != settings.jwt_secret: + logger.warning("Auth failed: invalid secret key for tg={}", telegram_id) + return None + + admin = await self.admin_repo.get_by_telegram_id(telegram_id) + if admin is None: + logger.warning("Auth failed: admin not found tg={}", telegram_id) + return None + + if not admin.is_active: + logger.warning("Auth failed: admin deactivated tg={}", telegram_id) + return None + + access_token = self.create_access_token(telegram_id, admin.role.value) + refresh_token = self.create_refresh_token(telegram_id) + + logger.info("Admin logged in: tg={} role={}", telegram_id, admin.role.value) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } + + def refresh_access_token(self, refresh_token: str) -> dict: + payload = self.decode_token(refresh_token) + + if payload.get("type") != "refresh": + raise ValueError("Invalid token type") + + telegram_id = int(payload["sub"]) + role = payload.get("role", "moderator") + + new_access = self.create_access_token(telegram_id, role) + + return { + "access_token": new_access, + "refresh_token": refresh_token, + "token_type": "bearer", + } diff --git a/app/services/billing.py b/app/services/billing.py new file mode 100644 index 0000000..99d5166 --- /dev/null +++ b/app/services/billing.py @@ -0,0 +1,149 @@ +from datetime import datetime, timedelta, timezone + +from loguru import logger + +from app.models.payment import PaymentStatus +from app.models.tariff import Tariff +from app.models.user import User +from app.repositories.payment import PaymentRepository +from app.repositories.tariff import TariffRepository +from app.repositories.user import UserRepository + + +class SubscriptionInfo: + def __init__( + self, + is_active: bool, + tariff: Tariff | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + remaining_days: int = 0, + auto_renew: bool = False, + ): + self.is_active = is_active + self.tariff = tariff + self.start_date = start_date + self.end_date = end_date + self.remaining_days = remaining_days + self.auto_renew = auto_renew + + +class BillingService: + def __init__( + self, + payment_repo: PaymentRepository, + tariff_repo: TariffRepository, + user_repo: UserRepository, + ): + self.payment_repo = payment_repo + self.tariff_repo = tariff_repo + self.user_repo = user_repo + + async def activate_subscription( + self, user_id: int, tariff_id: int, payment_id: int + ) -> SubscriptionInfo: + user = await self.user_repo.get(user_id) + if user is None: + raise ValueError(f"User not found: {user_id}") + + tariff = await self.tariff_repo.get(tariff_id) + if tariff is None: + raise ValueError(f"Tariff not found: {tariff_id}") + + payment = await self.payment_repo.get(payment_id) + if payment is None: + raise ValueError(f"Payment not found: {payment_id}") + + if payment.status != PaymentStatus.CONFIRMED: + raise ValueError(f"Payment {payment_id} is not confirmed") + + now = datetime.now(timezone.utc) + end_date = now + timedelta(days=tariff.duration_days) + + logger.info( + "Subscription activated: user={} tariff={} until={}", + user_id, + tariff.name, + end_date.date(), + ) + + return self._build_subscription_info(tariff, now, end_date) + + async def get_active_subscription(self, user_id: int) -> SubscriptionInfo | None: + user = await self.user_repo.get(user_id) + if user is None: + return None + + confirmed = await self.payment_repo.get_by_user_and_status( + user_id, PaymentStatus.CONFIRMED + ) + if not confirmed: + return SubscriptionInfo(is_active=False) + + latest = confirmed[0] + tariff = await self.tariff_repo.get(latest.tariff_id) + if tariff is None: + return SubscriptionInfo(is_active=False) + + now = datetime.now(timezone.utc) + start = latest.paid_at or latest.created_at + end = start + timedelta(days=tariff.duration_days) + + if now > end: + return SubscriptionInfo( + is_active=False, + tariff=tariff, + start_date=start, + end_date=end, + remaining_days=0, + ) + + remaining = (end - now).days + return SubscriptionInfo( + is_active=True, + tariff=tariff, + start_date=start, + end_date=end, + remaining_days=remaining, + ) + + async def has_active_subscription(self, user_id: int) -> bool: + info = await self.get_active_subscription(user_id) + return info is not None and info.is_active + + async def expire_subscriptions(self) -> list[int]: + expired_user_ids: list[int] = [] + + confirmed = await self.payment_repo.get_by_status(PaymentStatus.CONFIRMED) + now = datetime.now(timezone.utc) + + for payment in confirmed: + tariff = await self.tariff_repo.get(payment.tariff_id) + if tariff is None: + continue + + start = payment.paid_at or payment.created_at + end = start + timedelta(days=tariff.duration_days) + + if now > end: + expired_user_ids.append(payment.user_id) + logger.info( + "Subscription expired: user={} payment={}", + payment.user_id, + payment.id, + ) + + return expired_user_ids + + @staticmethod + def _build_subscription_info( + tariff: Tariff, start: datetime, end: datetime + ) -> SubscriptionInfo: + remaining = max(0, (end - datetime.now(timezone.utc)).days) + return SubscriptionInfo( + is_active=True, + tariff=tariff, + start_date=start, + end_date=end, + remaining_days=remaining, + ) diff --git a/app/services/notification.py b/app/services/notification.py new file mode 100644 index 0000000..231107d --- /dev/null +++ b/app/services/notification.py @@ -0,0 +1,202 @@ +from datetime import datetime, timedelta, timezone + +from aiogram import Bot +from aiogram.exceptions import TelegramAPIError +from loguru import logger + +from app.models.notification import Notification, NotificationType +from app.repositories.notification import NotificationRepository +from app.repositories.user import UserRepository +from app.settings import settings + + +_REMINDER_DAYS = (7, 3, 1) + +_DAYS_LABEL = {7: "7 days", 3: "3 days", 1: "1 day"} + + +class NotificationService: + def __init__( + self, + notification_repo: NotificationRepository, + user_repo: UserRepository, + bot: Bot | None = None, + ): + self.notification_repo = notification_repo + self.user_repo = user_repo + self._bot = bot + + def _get_bot(self) -> Bot | None: + if self._bot is None and settings.bot_token: + self._bot = Bot(token=settings.bot_token) + return self._bot + + async def send( + self, + user_id: int, + title: str, + text: str, + type: NotificationType = NotificationType.INFO, + ) -> Notification: + user = await self.user_repo.get(user_id) + if user is None: + raise ValueError(f"User not found: {user_id}") + + notification = await self.notification_repo.create( + user_id=user_id, + type=type, + title=title, + text=text, + ) + + await self._deliver(user, text) + + logger.debug( + "Notification sent: user={} type={} title={}", + user_id, + type.value, + title, + ) + return notification + + async def send_expiry_reminder( + self, user_id: int, remaining_days: int, tariff_name: str + ) -> Notification | None: + if remaining_days not in _REMINDER_DAYS: + return None + + user = await self.user_repo.get(user_id) + if user is None: + raise ValueError(f"User not found: {user_id}") + + label = _DAYS_LABEL.get(remaining_days, f"{remaining_days} days") + + notification = await self.notification_repo.create( + user_id=user_id, + type=NotificationType.WARNING, + title="Subscription Expiring Soon", + text=( + f"Your subscription ({tariff_name}) expires in {label}.\n" + "Please renew to avoid service interruption." + ), + ) + + await self._deliver( + user, + f"⚠️ Your {tariff_name} subscription expires in {label}.\n" + "Please renew to keep using the service.", + ) + + logger.info( + "Expiry reminder sent: user={} tariff={} expires_in={}d", + user_id, + tariff_name, + remaining_days, + ) + return notification + + async def send_subscription_expired( + self, user_id: int, tariff_name: str | None = None + ) -> Notification: + user = await self.user_repo.get(user_id) + if user is None: + raise ValueError(f"User not found: {user_id}") + + plan = tariff_name or "VPN" + notification = await self.notification_repo.create( + user_id=user_id, + type=NotificationType.WARNING, + title="Subscription Expired", + text=( + f"Your {plan} subscription has expired.\n" + "Please renew to restore access." + ), + ) + + await self._deliver( + user, + f"❌ Your {plan} subscription has expired.\n" + "Please renew to restore access.", + ) + + logger.info("Expired notification sent: user={} tariff={}", user_id, plan) + return notification + + async def _deliver(self, user, text: str) -> None: + bot = self._get_bot() + if bot is None: + logger.debug("No bot configured, notification not delivered") + return + + try: + await bot.send_message(chat_id=user.telegram_id, text=text) + except TelegramAPIError: + logger.warning( + "Failed to deliver to user {} (tg={}), fallback to admin group", + user.id, + user.telegram_id, + ) + await self._deliver_to_admin(text, user) + + async def _deliver_to_admin(self, text: str, user=None) -> None: + bot = self._get_bot() + if bot is None: + return + + chat_id = settings.admin_group_id + if not chat_id: + logger.debug("No admin group configured, notification dropped") + return + + prefix = ( + f"👤 User #{user.id} (@{user.username or '—'})\n\n" + if user + else "" + ) + message_id = settings.admin_group_thread_id + + try: + await bot.send_message( + chat_id=chat_id, + text=prefix + text, + message_thread_id=message_id, + ) + except TelegramAPIError as e: + logger.error("Failed to deliver to admin group: {}", e) + + async def send_info(self, user_id: int, title: str, text: str) -> Notification: + return await self.send(user_id, title, text, NotificationType.INFO) + + async def send_warning(self, user_id: int, title: str, text: str) -> Notification: + return await self.send(user_id, title, text, NotificationType.WARNING) + + async def send_success(self, user_id: int, title: str, text: str) -> Notification: + return await self.send(user_id, title, text, NotificationType.SUCCESS) + + async def send_payment( + self, user_id: int, title: str, text: str + ) -> Notification: + return await self.send(user_id, title, text, NotificationType.PAYMENT) + + async def get_user_notifications( + self, user_id: int + ) -> list[Notification]: + return await self.notification_repo.get_by_user_id(user_id) + + async def get_unread(self, user_id: int) -> list[Notification]: + return await self.notification_repo.get_unread(user_id) + + async def get_unread_count(self, user_id: int) -> int: + return await self.notification_repo.count_unread(user_id) + + async def mark_read(self, notification_id: int) -> bool: + result = await self.notification_repo.mark_as_read(notification_id) + if result: + logger.debug("Notification marked read: id={}", notification_id) + return result + + async def mark_all_read(self, user_id: int) -> int: + count = await self.notification_repo.mark_all_as_read(user_id) + if count: + logger.debug("Marked {} notifications as read: user={}", count, user_id) + return count diff --git a/app/services/payment.py b/app/services/payment.py new file mode 100644 index 0000000..c875c98 --- /dev/null +++ b/app/services/payment.py @@ -0,0 +1,109 @@ +from datetime import datetime, timezone + +from loguru import logger + +from app.models.payment import Payment, PaymentStatus +from app.repositories.payment import PaymentRepository +from app.repositories.tariff import TariffRepository +from app.repositories.user import UserRepository + + +class PaymentService: + def __init__( + self, + payment_repo: PaymentRepository, + user_repo: UserRepository, + tariff_repo: TariffRepository, + ): + self.payment_repo = payment_repo + self.user_repo = user_repo + self.tariff_repo = tariff_repo + + async def create( + self, + user_id: int, + tariff_id: int, + provider: str, + amount: float, + currency: str = "RUB", + external_id: str | None = None, + ) -> Payment: + user = await self.user_repo.get(user_id) + if user is None: + raise ValueError(f"User not found: {user_id}") + + tariff = await self.tariff_repo.get(tariff_id) + if tariff is None: + raise ValueError(f"Tariff not found: {tariff_id}") + + payment = await self.payment_repo.create( + user_id=user_id, + tariff_id=tariff_id, + amount=amount, + currency=currency, + status=PaymentStatus.PENDING, + provider=provider, + external_id=external_id, + ) + + logger.info("Payment created: id={} user={} amount={}", payment.id, user_id, amount) + return payment + + async def confirm( + self, payment_id: int, external_id: str | None = None + ) -> Payment: + payment = await self.payment_repo.get(payment_id) + if payment is None: + raise ValueError(f"Payment not found: {payment_id}") + + if payment.status != PaymentStatus.PENDING: + raise ValueError( + f"Cannot confirm payment {payment_id}: " + f"current status is {payment.status.value}" + ) + + payment = await self.payment_repo.update( + payment_id, + status=PaymentStatus.CONFIRMED, + paid_at=datetime.now(timezone.utc), + external_id=external_id or payment.external_id, + ) + + logger.info("Payment confirmed: id={} user={}", payment_id, payment.user_id) + return payment + + async def fail(self, payment_id: int) -> Payment: + payment = await self.payment_repo.get(payment_id) + if payment is None: + raise ValueError(f"Payment not found: {payment_id}") + + payment = await self.payment_repo.update( + payment_id, status=PaymentStatus.FAILED + ) + + logger.info("Payment failed: id={} user={}", payment_id, payment.user_id) + return payment + + async def refund(self, payment_id: int) -> Payment: + payment = await self.payment_repo.get(payment_id) + if payment is None: + raise ValueError(f"Payment not found: {payment_id}") + + payment = await self.payment_repo.update( + payment_id, status=PaymentStatus.REFUNDED + ) + + logger.info("Payment refunded: id={} user={}", payment_id, payment.user_id) + return payment + + async def get_by_id(self, payment_id: int) -> Payment | None: + return await self.payment_repo.get(payment_id) + + async def get_by_external_id(self, external_id: str) -> Payment | None: + return await self.payment_repo.get_by_external_id(external_id) + + async def get_user_payments(self, user_id: int) -> list[Payment]: + return await self.payment_repo.get_by_user_id(user_id) + + async def get_pending(self) -> list[Payment]: + return await self.payment_repo.get_pending_payments() diff --git a/app/services/server.py b/app/services/server.py new file mode 100644 index 0000000..c23d06f --- /dev/null +++ b/app/services/server.py @@ -0,0 +1,71 @@ +from datetime import datetime, timezone + +from loguru import logger + +from app.models.server import Server, ServerProtocol +from app.repositories.server import ServerRepository + + +class ServerService: + def __init__(self, server_repo: ServerRepository): + self.server_repo = server_repo + + async def create( + self, + name: str, + host: str, + port: int, + protocol: ServerProtocol, + location: str, + country_code: str, + max_users: int = 100, + ) -> Server: + existing = await self.server_repo.get_by_name(name) + if existing is not None: + raise ValueError(f"Server already exists: {name}") + + server = await self.server_repo.create( + name=name, + host=host, + port=port, + protocol=protocol, + location=location, + country_code=country_code.upper(), + max_users=max_users, + ) + + logger.info("Server created: id={} name={} proto={}", server.id, name, protocol.value) + return server + + async def get_by_id(self, server_id: int) -> Server | None: + return await self.server_repo.get(server_id) + + async def get_active(self) -> list[Server]: + return await self.server_repo.get_active() + + async def get_by_protocol(self, protocol: ServerProtocol) -> list[Server]: + return await self.server_repo.get_by_protocol(protocol) + + async def get_least_loaded(self, protocol: ServerProtocol) -> Server | None: + return await self.server_repo.get_least_loaded(protocol) + + async def update_load(self, server_id: int, load_percent: int) -> Server | None: + load = max(0, min(100, load_percent)) + return await self.server_repo.update(server_id, load_percent=load) + + async def mark_online(self, server_id: int) -> Server | None: + return await self.server_repo.update(server_id, is_active=True) + + async def mark_offline(self, server_id: int) -> Server | None: + server = await self.server_repo.update(server_id, is_active=False) + if server: + logger.warning("Server marked offline: id={} name={}", server_id, server.name) + return server + + async def update( + self, server_id: int, **kwargs + ) -> Server | None: + return await self.server_repo.update(server_id, **kwargs) + + async def get_count(self) -> int: + return await self.server_repo.count_active() diff --git a/app/services/stats.py b/app/services/stats.py new file mode 100644 index 0000000..fa6cd7d --- /dev/null +++ b/app/services/stats.py @@ -0,0 +1,84 @@ +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.payment import Payment, PaymentStatus +from app.models.tariff import Tariff +from app.models.user import User +from app.repositories.payment import PaymentRepository +from app.repositories.tariff import TariffRepository +from app.repositories.user import UserRepository +from app.services.billing import BillingService + + +@dataclass +class SystemStats: + total_users: int = 0 + active_users: int = 0 + expired_subscriptions: int = 0 + revenue_month: float = 0.0 + revenue_year: float = 0.0 + avg_subscription_price: float = 0.0 + + +class StatsService: + def __init__(self, session: AsyncSession): + self.session = session + + async def get_stats(self) -> SystemStats: + total_users = await self._count_users() + active_users = await self._count_active_users() + expired_subscriptions = await self._count_expired() + revenue_month = await self._sum_revenue_since(self._start_of_month()) + revenue_year = await self._sum_revenue_since(self._start_of_year()) + avg_subscription_price = await self._avg_tariff_price() + + return SystemStats( + total_users=total_users, + active_users=active_users, + expired_subscriptions=expired_subscriptions, + revenue_month=round(revenue_month, 2), + revenue_year=round(revenue_year, 2), + avg_subscription_price=round(avg_subscription_price, 2), + ) + + async def _count_users(self) -> int: + stmt = select(func.count(User.id)) + return (await self.session.execute(stmt)).scalar_one() + + async def _count_active_users(self) -> int: + stmt = select(func.count(User.id)).where(User.is_active.is_(True)) + return (await self.session.execute(stmt)).scalar_one() + + async def _count_expired(self) -> int: + user_repo = UserRepository(self.session) + tariff_repo = TariffRepository(self.session) + payment_repo = PaymentRepository(self.session) + + billing = BillingService(payment_repo, tariff_repo, user_repo) + expired_ids = await billing.expire_subscriptions() + return len(set(expired_ids)) + + async def _sum_revenue_since(self, since: datetime) -> float: + stmt = ( + select(func.coalesce(func.sum(Payment.amount), 0)) + .where(Payment.status == PaymentStatus.CONFIRMED) + .where(Payment.paid_at >= since) + ) + return float((await self.session.execute(stmt)).scalar_one()) + + async def _avg_tariff_price(self) -> float: + stmt = select(func.coalesce(func.avg(Tariff.price), 0)) + return float((await self.session.execute(stmt)).scalar_one()) + + @staticmethod + def _start_of_month() -> datetime: + now = datetime.now(timezone.utc) + return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + @staticmethod + def _start_of_year() -> datetime: + now = datetime.now(timezone.utc) + return now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) diff --git a/app/services/subscription.py b/app/services/subscription.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/user.py b/app/services/user.py new file mode 100644 index 0000000..05d92d4 --- /dev/null +++ b/app/services/user.py @@ -0,0 +1,85 @@ +from datetime import datetime + +from loguru import logger + +from app.models.user import User +from app.repositories.user import UserRepository + + +class UserService: + def __init__(self, user_repo: UserRepository): + self.user_repo = user_repo + + async def register( + self, + telegram_id: int, + username: str | None, + full_name: str, + language_code: str, + ) -> User: + user = await self.user_repo.get_by_telegram_id(telegram_id) + if user is not None: + user = await self.user_repo.update( + user.id, + username=username, + full_name=full_name, + language_code=language_code, + ) + logger.debug("User profile updated: tg={}", telegram_id) + return user + + user = await self.user_repo.create( + telegram_id=telegram_id, + username=username, + full_name=full_name, + language_code=language_code, + ) + logger.info("New user registered: tg={} id={}", telegram_id, user.id) + return user + + async def get_or_create( + self, + telegram_id: int, + username: str | None = None, + full_name: str = "", + language_code: str = "ru", + ) -> User: + user = await self.user_repo.get_by_telegram_id(telegram_id) + if user is not None: + return user + return await self.user_repo.create( + telegram_id=telegram_id, + username=username, + full_name=full_name or str(telegram_id), + language_code=language_code, + ) + + async def get_by_id(self, user_id: int) -> User | None: + return await self.user_repo.get(user_id) + + async def get_by_telegram_id(self, telegram_id: int) -> User | None: + return await self.user_repo.get_by_telegram_id(telegram_id) + + async def update(self, user_id: int, **kwargs) -> User | None: + return await self.user_repo.update(user_id, **kwargs) + + async def deactivate(self, user_id: int) -> bool: + user = await self.user_repo.update(user_id, is_active=False) + if user: + logger.info("User deactivated: id={}", user_id) + return True + return False + + async def activate(self, user_id: int) -> bool: + user = await self.user_repo.update(user_id, is_active=True) + if user: + logger.info("User activated: id={}", user_id) + return True + return False + + async def get_active_count(self) -> int: + users = await self.user_repo.get_active_users() + return len(users) + + async def get_registered_since(self, since: datetime) -> list[User]: + return await self.user_repo.get_by_created_range(since, datetime.utcnow()) diff --git a/app/services/vpn.py b/app/services/vpn.py new file mode 100644 index 0000000..32c8b18 --- /dev/null +++ b/app/services/vpn.py @@ -0,0 +1,56 @@ +from loguru import logger + +from app.models.server import Server, ServerProtocol +from app.models.user import User +from app.repositories.server import ServerRepository +from app.repositories.user import UserRepository + + +class VPNService: + def __init__( + self, + server_repo: ServerRepository, + user_repo: UserRepository, + ): + self.server_repo = server_repo + self.user_repo = user_repo + + async def assign_server( + self, user_id: int, protocol: ServerProtocol | None = None + ) -> Server: + user = await self.user_repo.get(user_id) + if user is None: + raise ValueError(f"User not found: {user_id}") + + if protocol is not None: + server = await self.server_repo.get_least_loaded(protocol) + else: + for proto in ServerProtocol: + server = await self.server_repo.get_least_loaded(proto) + if server is not None: + break + + if server is None: + raise RuntimeError("No available VPN servers") + + return server + + async def get_available_protocols(self) -> list[ServerProtocol]: + servers = await self.server_repo.get_active() + return list({s.protocol for s in servers}) + + async def get_servers_by_protocol( + self, protocol: ServerProtocol + ) -> list[Server]: + return await self.server_repo.get_by_protocol(protocol) + + async def get_user_server( + self, user_id: int + ) -> Server | None: + return await self.server_repo.get_least_loaded(ServerProtocol.WIREGUARD) + + async def is_server_available(self, server_id: int) -> bool: + server = await self.server_repo.get(server_id) + if server is None: + return False + return server.is_active and server.load_percent < 100 diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..8f36bc2 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,53 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + app_name: str = "VPN Control Panel" + debug: bool = False + port: int = 8000 + + postgres_user: str = Field(default="vpn", alias="POSTGRES_USER") + postgres_password: str = Field(default="vpn_secret", alias="POSTGRES_PASSWORD") + postgres_db: str = Field(default="vpn_control", alias="POSTGRES_DB") + postgres_host: str = Field(default="localhost", alias="POSTGRES_HOST") + postgres_port: int = Field(default=5432, alias="POSTGRES_PORT") + + @property + def database_url(self) -> str: + return ( + f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}" + f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}" + ) + + @property + def database_url_sync(self) -> str: + return ( + f"postgresql://{self.postgres_user}:{self.postgres_password}" + f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}" + ) + + redis_url: str = "redis://localhost:6379/0" + + bot_token: str = "" + admin_ids: list[int] = Field(default_factory=list) + admin_group_id: int = 0 + admin_group_thread_id: int | None = None + + jwt_secret: str = "change_me" + jwt_algorithm: str = "HS256" + jwt_access_expire_minutes: int = 30 + jwt_refresh_expire_days: int = 30 + + outline_api_prefix: str = "" + outline_cert_sha256: str = "" + + +settings = Settings() diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..11e3707 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,69 @@ + + + + + + {% block title %}VPN Control Panel{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..03fad4a --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Dashboard — VPN Control Panel{% endblock %} +{% block content %} +

Dashboard

+ +
+
+
{{ active_users }} / {{ total_users }}
+
Active Users
+
+
+
{{ total_tariffs }}
+
Tariffs
+
+
+
{{ active_servers }} / {{ total_servers }}
+
Active Servers
+
+
+
{{ confirmed_payments }} / {{ total_payments }}
+
Confirmed Payments
+
+
+
{{ "%.2f"|format(total_revenue) }}
+
Revenue (RUB)
+
+
+{% endblock %} diff --git a/app/templates/payments.html b/app/templates/payments.html new file mode 100644 index 0000000..092fe15 --- /dev/null +++ b/app/templates/payments.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Payments — VPN Control Panel{% endblock %} +{% block content %} +

Payments

+
{{ payments|length }} total
+ + + + + + + + + + + + + + + {% for p in payments %} + + + + + + + + + + + {% endfor %} + +
IDUserTariffAmountStatusProviderCreatedPaid At
{{ p.id }}#{{ p.user_id }}{{ tariffs_map.get(p.tariff_id, "—") }}{{ "%.2f"|format(p.amount) }} {{ p.currency }} + {% if p.status.value == "confirmed" %} + Confirmed + {% elif p.status.value == "pending" %} + Pending + {% elif p.status.value == "failed" %} + Failed + {% elif p.status.value == "refunded" %} + Refunded + {% else %} + {{ p.status.value }} + {% endif %} + {{ p.provider }}{{ p.created_at.strftime("%Y-%m-%d %H:%M") }}{{ p.paid_at.strftime("%Y-%m-%d %H:%M") if p.paid_at else "—" }}
+{% endblock %} diff --git a/app/templates/servers.html b/app/templates/servers.html new file mode 100644 index 0000000..7488f3a --- /dev/null +++ b/app/templates/servers.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Servers — VPN Control Panel{% endblock %} +{% block content %} +

Servers

+
{{ servers|length }} total
+ + + + + + + + + + + + + + + + {% for s in servers %} + + + + + + + + + + + + {% endfor %} + +
IDNameHostPortProtocolLocationLoadStatusCreated
{{ s.id }}{{ s.name }}{{ s.host }}{{ s.port }}{{ s.protocol.value }}{{ s.location }} ({{ s.country_code }}){{ s.load_percent }}% + {% if s.is_active %} + Active + {% else %} + Offline + {% endif %} + {{ s.created_at.strftime("%Y-%m-%d") }}
+{% endblock %} diff --git a/app/templates/tariffs.html b/app/templates/tariffs.html new file mode 100644 index 0000000..eaf7d9f --- /dev/null +++ b/app/templates/tariffs.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Tariffs — VPN Control Panel{% endblock %} +{% block content %} +

Tariffs

+
{{ tariffs|length }} total
+ + + + + + + + + + + + + + + {% for t in tariffs %} + + + + + + + + + + + {% endfor %} + +
IDNameDurationPriceMax DevicesTrafficStatusCreated
{{ t.id }}{{ t.name }}{{ t.duration_days }} days{{ "%.2f"|format(t.price) }} {{ t.currency }}{{ t.max_devices }}{% if t.traffic_gb %}{{ t.traffic_gb }} GB{% else %}Unlimited{% endif %} + {% if t.is_active %} + Active + {% else %} + Inactive + {% endif %} + {{ t.created_at.strftime("%Y-%m-%d") }}
+{% endblock %} diff --git a/app/templates/users.html b/app/templates/users.html new file mode 100644 index 0000000..d588541 --- /dev/null +++ b/app/templates/users.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Users — VPN Control Panel{% endblock %} +{% block content %} +

Users

+
{{ users|length }} total
+ + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% endfor %} + +
IDTelegram IDUsernameFull NameLanguageStatusCreated
{{ u.id }}{{ u.telegram_id }}{% if u.username %}@{{ u.username }}{% else %}—{% endif %}{{ u.full_name }}{{ u.language_code }} + {% if u.is_active %} + Active + {% else %} + Inactive + {% endif %} + {{ u.created_at.strftime("%Y-%m-%d") }}
+{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/crypto.py b/app/utils/crypto.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/pagination.py b/app/utils/pagination.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..e23b4e4 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,23 @@ +version: "3.9" + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: vpn + POSTGRES_PASSWORD: vpn_secret + POSTGRES_DB: vpn_control + ports: + - "5432:5432" + volumes: + - pgdata_dev:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + +volumes: + pgdata_dev: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b9771b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +version: "3.9" + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-vpn} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vpn_secret} + POSTGRES_DB: ${POSTGRES_DB:-vpn_control} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U vpn"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: + context: . + dockerfile: docker/Dockerfile + restart: unless-stopped + ports: + - "${PORT:-8000}:8000" + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + +volumes: + pgdata: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..9e5aee2 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.13-slim AS builder + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --user -r requirements.txt + +FROM python:3.13-slim + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /root/.local /root/.local + +ENV PATH=/root/.local/bin:$PATH \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY . . + +RUN chmod +x docker/entrypoint.sh + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=15s \ + CMD curl -f http://localhost:8000/health || exit 1 + +ENTRYPOINT ["docker/entrypoint.sh"] diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..3a51716 --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,14 @@ +FROM python:3.13-slim + +WORKDIR /app + +RUN pip install --no-cache-dir -r requirements.dev.txt + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..51b5577 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "Running database migrations..." +alembic upgrade head + +echo "Starting application..." +exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000} diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..18f713d --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,59 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.database import Base +from app.models import User, Server, Tariff, Payment, Notification, Admin +from app.settings import settings + +config = context.config + +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = settings.database_url_sync + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + config_section = config.get_section(config.config_ini_section) + connectable = async_engine_from_config( + config_section, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/.gitkeep b/migrations/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/versions/b6a9a792b84d_initial.py b/migrations/versions/b6a9a792b84d_initial.py new file mode 100644 index 0000000..701114a --- /dev/null +++ b/migrations/versions/b6a9a792b84d_initial.py @@ -0,0 +1,114 @@ +"""initial + +Revision ID: b6a9a792b84d +Revises: +Create Date: 2026-07-05 17:27:35.461505 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b6a9a792b84d' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('admins', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('telegram_id', sa.BigInteger(), nullable=False), + sa.Column('username', sa.String(length=64), nullable=True), + sa.Column('role', sa.Enum('SUPERADMIN', 'MODERATOR', name='admin_role'), nullable=False), + sa.Column('permissions', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('telegram_id') + ) + op.create_table('servers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('host', sa.String(length=255), nullable=False), + sa.Column('port', sa.Integer(), nullable=False), + sa.Column('protocol', sa.Enum('OUTLINE', 'WIREGUARD', 'XRAY', name='server_protocol'), nullable=False), + sa.Column('location', sa.String(length=128), nullable=False), + sa.Column('country_code', sa.String(length=4), nullable=False), + sa.Column('load_percent', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('max_users', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('tariffs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('duration_days', sa.Integer(), nullable=False), + sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('currency', sa.String(length=3), nullable=False), + sa.Column('max_devices', sa.Integer(), nullable=False), + sa.Column('traffic_gb', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('telegram_id', sa.BigInteger(), nullable=False), + sa.Column('username', sa.String(length=64), nullable=True), + sa.Column('full_name', sa.String(length=128), nullable=False), + sa.Column('language_code', sa.String(length=8), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('telegram_id') + ) + op.create_table('notifications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('type', sa.Enum('INFO', 'WARNING', 'SUCCESS', 'PAYMENT', name='notification_type'), nullable=False), + sa.Column('title', sa.String(length=128), nullable=False), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('is_read', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('payments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('tariff_id', sa.Integer(), nullable=False), + sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('currency', sa.String(length=3), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'CONFIRMED', 'FAILED', 'REFUNDED', name='payment_status'), nullable=False), + sa.Column('provider', sa.String(length=32), nullable=False), + sa.Column('external_id', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['tariff_id'], ['tariffs.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('payments') + op.drop_table('notifications') + op.drop_table('users') + op.drop_table('tariffs') + op.drop_table('servers') + op.drop_table('admins') + op.execute("DROP TYPE IF EXISTS payment_status") + op.execute("DROP TYPE IF EXISTS notification_type") + op.execute("DROP TYPE IF EXISTS server_protocol") + op.execute("DROP TYPE IF EXISTS admin_role") + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a79d59b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "vpn-control-panel" +version = "0.1.0" +description = "VPN user and subscription management system" +requires-python = ">=3.13" +license = {text = "MIT"} + +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.0", + "greenlet>=3.0.0", + "asyncpg>=0.30.0", + "alembic>=1.14.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "aiogram>=3.0.0", + "apscheduler>=3.10.0", + "redis>=5.0.0", + "httpx>=0.27.0", + "python-jose[cryptography]>=3.3.0", + "loguru>=0.7.0", + "jinja2>=3.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.5.0", + "httpx>=0.27.0", +] + +[build-system] +requires = ["setuptools>=75.0"] +build-backend = "setuptools.build_meta" + +[tool.ruff] +target-version = "py313" +line-length = 100 + +[tool.ruff.format] +quote-style = "double" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..97a805e --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=8.0.0 +pytest-asyncio>=0.24.0 +ruff>=0.5.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0bd60de --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +sqlalchemy>=2.0.0 +greenlet>=3.0.0 +asyncpg>=0.30.0 +alembic>=1.14.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +aiogram>=3.0.0 +apscheduler>=3.10.0 +redis>=5.0.0 +httpx>=0.27.0 +python-jose[cryptography]>=3.3.0 +loguru>=0.7.0 +jinja2>=3.1.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api/__init__.py b/tests/test_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_bot/__init__.py b/tests/test_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_providers/__init__.py b/tests/test_providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_services/__init__.py b/tests/test_services/__init__.py new file mode 100644 index 0000000..e69de29