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:
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
140
app/api/deps.py
Normal file
140
app/api/deps.py
Normal 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
0
app/api/middleware.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/admin.py
Normal file
0
app/api/v1/admin.py
Normal file
35
app/api/v1/auth.py
Normal file
35
app/api/v1/auth.py
Normal 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
107
app/api/v1/payments.py
Normal 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
17
app/api/v1/router.py
Normal 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
66
app/api/v1/servers.py
Normal 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
69
app/api/v1/stats.py
Normal 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),
|
||||
)
|
||||
0
app/api/v1/subscriptions.py
Normal file
0
app/api/v1/subscriptions.py
Normal file
32
app/api/v1/tariffs.py
Normal file
32
app/api/v1/tariffs.py
Normal 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
83
app/api/v1/users.py
Normal 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
0
app/api/v1/vpn.py
Normal file
Reference in New Issue
Block a user