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
This commit is contained in:
2026-07-05 17:50:11 +07:00
commit 7c7c88621d
117 changed files with 4456 additions and 0 deletions

0
app/api/__init__.py Normal file
View File

140
app/api/deps.py Normal file
View File

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

0
app/api/middleware.py Normal file
View File

0
app/api/v1/__init__.py Normal file
View File

0
app/api/v1/admin.py Normal file
View File

35
app/api/v1/auth.py Normal file
View File

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

107
app/api/v1/payments.py Normal file
View File

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

17
app/api/v1/router.py Normal file
View File

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

66
app/api/v1/servers.py Normal file
View File

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

69
app/api/v1/stats.py Normal file
View File

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

View File

32
app/api/v1/tariffs.py Normal file
View File

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

83
app/api/v1/users.py Normal file
View File

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

0
app/api/v1/vpn.py Normal file
View File