start
This commit is contained in:
2
api/README.md
Normal file
2
api/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Для запуска API отдельно:
|
||||
# uvicorn api.main:app --reload
|
||||
28
api/config.py
Normal file
28
api/config.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
|
||||
def _env(*names, default=None):
|
||||
for n in names:
|
||||
v = os.getenv(n)
|
||||
if v:
|
||||
return v
|
||||
return default
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# support both custom DB_* names and common POSTGRES_* names from postgres images
|
||||
DB_USER: str = _env("DB_USER", "POSTGRES_USER", default="postgres")
|
||||
DB_PASS: str = _env("DB_PASS", "POSTGRES_PASSWORD", default="postgres")
|
||||
DB_NAME: str = _env("DB_NAME", "POSTGRES_DB", default="vpn_bot_db")
|
||||
DB_HOST: str = _env("DB_HOST", "POSTGRES_HOST", "DB_HOST", default="db")
|
||||
DB_PORT: str = _env("DB_PORT", "POSTGRES_PORT", default="5432")
|
||||
|
||||
# If explicit DB_* variables are provided, build DATABASE_URL from them (priority).
|
||||
# Otherwise fall back to explicit DATABASE_URL or DATABASE_URL_ASYNC env.
|
||||
_built_db_url: str | None = None
|
||||
if DB_USER and DB_PASS and DB_NAME and DB_HOST and DB_PORT:
|
||||
_built_db_url = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
|
||||
DATABASE_URL: str = _built_db_url or _env("DATABASE_URL", "DATABASE_URL_ASYNC", default="")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
27
api/db/connection.py
Normal file
27
api/db/connection.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import text
|
||||
import asyncio
|
||||
from api.config import settings
|
||||
|
||||
DATABASE_URL = settings.DATABASE_URL
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
||||
SessionLocal = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
async def wait_for_db(retries: int = 12, delay: float = 1.0) -> None:
|
||||
"""Wait until the database is available. Retries with exponential backoff.
|
||||
|
||||
Raises RuntimeError if DB is still unavailable after retries.
|
||||
"""
|
||||
last_exc = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
return
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
wait = min(delay * (2 ** (attempt - 1)), 5)
|
||||
await asyncio.sleep(wait)
|
||||
raise RuntimeError(f"Could not connect to DB after {retries} attempts") from last_exc
|
||||
121
api/db/subscription.py
Normal file
121
api/db/subscription.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from api.db.connection import engine, SessionLocal
|
||||
from api.models.subscription import Subscription
|
||||
from api.models.base import Base
|
||||
from api.config import settings
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select
|
||||
import asyncpg
|
||||
|
||||
|
||||
async def init_db():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return
|
||||
except Exception as exc: # try to create database if it doesn't exist
|
||||
msg = str(exc).lower()
|
||||
if "does not exist" in msg or "invalidcatalogname" in msg or "database" in msg:
|
||||
try:
|
||||
admin_conn = await asyncpg.connect(
|
||||
user=settings.DB_USER,
|
||||
password=settings.DB_PASS,
|
||||
database="postgres",
|
||||
host=settings.DB_HOST,
|
||||
port=int(settings.DB_PORT),
|
||||
)
|
||||
try:
|
||||
await admin_conn.execute(f'CREATE DATABASE "{settings.DB_NAME}"')
|
||||
except Exception:
|
||||
pass
|
||||
await admin_conn.close()
|
||||
except Exception:
|
||||
raise
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
async def create_subscription(user_id: str, uuid: str, until: datetime, subscription_link: str | None = None, connect_link: str | None = None, active: bool = True):
|
||||
async with SessionLocal() as session:
|
||||
# if user already has a subscription, remove it (replace)
|
||||
q = select(Subscription).where(Subscription.user_id == user_id)
|
||||
res = await session.execute(q)
|
||||
existing = res.scalars().first()
|
||||
if existing:
|
||||
await session.delete(existing)
|
||||
# avoid NULL in DB: replace None links with empty strings
|
||||
safe_subscription_link = subscription_link if subscription_link is not None else ""
|
||||
safe_connect_link = connect_link if connect_link is not None else ""
|
||||
sub = Subscription(user_id=user_id, uuid=uuid, until=until, active=active, subscription_link=safe_subscription_link, connect_link=safe_connect_link)
|
||||
session.add(sub)
|
||||
await session.commit()
|
||||
await session.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
async def get_subscription_by_user(user_id: str):
|
||||
async with SessionLocal() as session:
|
||||
q = select(Subscription).where(Subscription.user_id == user_id)
|
||||
res = await session.execute(q)
|
||||
sub = res.scalars().first()
|
||||
return sub
|
||||
|
||||
|
||||
async def get_subscription_by_uuid(sub_uuid: str):
|
||||
async with SessionLocal() as session:
|
||||
q = select(Subscription).where(Subscription.uuid == sub_uuid)
|
||||
res = await session.execute(q)
|
||||
return res.scalars().first()
|
||||
|
||||
|
||||
async def update_subscription(sub_uuid: str, until: datetime = None, user_id: str | None = None, active: bool | None = None, subscription_link: str | None = None, connect_link: str | None = None):
|
||||
async with SessionLocal() as session:
|
||||
q = select(Subscription).where(Subscription.uuid == sub_uuid)
|
||||
res = await session.execute(q)
|
||||
sub = res.scalars().first()
|
||||
if not sub:
|
||||
return None
|
||||
if until is not None:
|
||||
sub.until = until
|
||||
if user_id is not None:
|
||||
sub.user_id = user_id
|
||||
if active is not None:
|
||||
sub.active = active
|
||||
if subscription_link is not None:
|
||||
sub.subscription_link = subscription_link
|
||||
if connect_link is not None:
|
||||
sub.connect_link = connect_link
|
||||
await session.commit()
|
||||
return sub
|
||||
|
||||
|
||||
async def delete_subscription(sub_uuid: str):
|
||||
async with SessionLocal() as session:
|
||||
q = select(Subscription).where(Subscription.uuid == sub_uuid)
|
||||
res = await session.execute(q)
|
||||
sub = res.scalars().first()
|
||||
if not sub:
|
||||
return False
|
||||
await session.delete(sub)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def list_subscriptions():
|
||||
async with SessionLocal() as session:
|
||||
q = select(Subscription)
|
||||
res = await session.execute(q)
|
||||
return res.scalars().all()
|
||||
|
||||
|
||||
async def extend_subscription_by_user(user_id: str, months: int):
|
||||
sub = await get_subscription_by_user(user_id)
|
||||
if sub:
|
||||
new_until = max(sub.until, datetime.now()) + timedelta(days=30 * months)
|
||||
return await update_subscription(sub.uuid, until=new_until, active=True)
|
||||
return None
|
||||
|
||||
|
||||
async def deactivate_subscription(sub_uuid: str):
|
||||
return await update_subscription(sub_uuid, active=False)
|
||||
64
api/db/user.py
Normal file
64
api/db/user.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from api.db.connection import SessionLocal
|
||||
from api.models.user import User
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
async def create_user(telegram_id: str, name: str | None = None):
|
||||
async with SessionLocal() as session:
|
||||
# ensure no NULL in DB: replace None with empty string
|
||||
safe_name = name if name is not None else ""
|
||||
user = User(id=str(uuid.uuid4()), telegram_id=telegram_id, name=safe_name)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def get_user(user_id: str):
|
||||
async with SessionLocal() as session:
|
||||
q = select(User).where(User.id == user_id)
|
||||
res = await session.execute(q)
|
||||
return res.scalars().first()
|
||||
|
||||
|
||||
async def get_user_by_telegram(telegram_id: str):
|
||||
async with SessionLocal() as session:
|
||||
q = select(User).where(User.telegram_id == telegram_id)
|
||||
res = await session.execute(q)
|
||||
return res.scalars().first()
|
||||
|
||||
|
||||
async def update_user(user_id: str, name: str | None = None):
|
||||
async with SessionLocal() as session:
|
||||
q = select(User).where(User.id == user_id)
|
||||
res = await session.execute(q)
|
||||
user = res.scalars().first()
|
||||
if not user:
|
||||
return None
|
||||
if name is not None:
|
||||
# replace None handled by caller; here update only when provided
|
||||
user.name = name
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def delete_user(user_id: str):
|
||||
async with SessionLocal() as session:
|
||||
q = select(User).where(User.id == user_id)
|
||||
res = await session.execute(q)
|
||||
user = res.scalars().first()
|
||||
if not user:
|
||||
return False
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def list_users():
|
||||
async with SessionLocal() as session:
|
||||
q = select(User)
|
||||
res = await session.execute(q)
|
||||
return res.scalars().all()
|
||||
17
api/main.py
Normal file
17
api/main.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import asyncio
|
||||
from fastapi import FastAPI
|
||||
from api.route import default, subscription
|
||||
from api.route import users
|
||||
from api.db.subscription import init_db
|
||||
from api.db.connection import wait_for_db
|
||||
|
||||
app = FastAPI(title="VPN Bot API")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
await wait_for_db()
|
||||
await init_db()
|
||||
|
||||
app.include_router(default.router)
|
||||
app.include_router(subscription.router)
|
||||
app.include_router(users.router)
|
||||
3
api/models/base.py
Normal file
3
api/models/base.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
14
api/models/subscription.py
Normal file
14
api/models/subscription.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean, Text
|
||||
from api.models.base import Base
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = 'subscriptions'
|
||||
uuid = Column(String, primary_key=True, index=True)
|
||||
# one subscription per user (unique)
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=False, index=True, unique=True)
|
||||
until = Column(DateTime, nullable=False)
|
||||
active = Column(Boolean, nullable=False, default=True)
|
||||
# avoid NULL: store empty string when no link provided
|
||||
subscription_link = Column(Text, nullable=False, default="")
|
||||
connect_link = Column(Text, nullable=False, default="")
|
||||
12
api/models/user.py
Normal file
12
api/models/user.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import Column, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from api.models.base import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = 'users'
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
telegram_id = Column(String, unique=True, index=True, nullable=False)
|
||||
name = Column(String, nullable=False, default="")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
17
api/route/default.py
Normal file
17
api/route/default.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
import toml
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
version = "dev"
|
||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
try:
|
||||
data = toml.load(pyproject_path)
|
||||
version = data.get("project", {}).get("version", "dev")
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "ok", "msg": "VPN Bot API", "version": version}
|
||||
94
api/route/subscription.py
Normal file
94
api/route/subscription.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from api.db.subscription import (
|
||||
create_subscription,
|
||||
get_subscription_by_user,
|
||||
get_subscription_by_uuid,
|
||||
update_subscription,
|
||||
delete_subscription,
|
||||
list_subscriptions,
|
||||
extend_subscription_by_user,
|
||||
)
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
router = APIRouter(prefix="/subscription", tags=["subscription"])
|
||||
|
||||
|
||||
class SubscriptionInfo(BaseModel):
|
||||
user_id: str
|
||||
uuid: str
|
||||
until: datetime
|
||||
active: bool
|
||||
subscription_link: Optional[str] = None
|
||||
connect_link: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionCreate(BaseModel):
|
||||
user_id: str
|
||||
months: int
|
||||
subscription_link: Optional[str] = None
|
||||
connect_link: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionUpdate(BaseModel):
|
||||
until: Optional[datetime]
|
||||
user_id: Optional[str]
|
||||
active: Optional[bool] = None
|
||||
subscription_link: Optional[str] = None
|
||||
connect_link: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=Optional[SubscriptionInfo])
|
||||
async def get_user_subscription(user_id: str):
|
||||
sub = await get_subscription_by_user(user_id)
|
||||
if not sub:
|
||||
return None
|
||||
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until)
|
||||
|
||||
|
||||
@router.post("/new", response_model=SubscriptionInfo)
|
||||
async def create_subscription_endpoint(data: SubscriptionCreate):
|
||||
sub_id = str(uuid.uuid4())
|
||||
until = datetime.now() + timedelta(days=30 * data.months)
|
||||
sub = await create_subscription(data.user_id, sub_id, until, subscription_link=data.subscription_link, connect_link=data.connect_link, active=True)
|
||||
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until)
|
||||
|
||||
|
||||
@router.post("/extend", response_model=SubscriptionInfo)
|
||||
async def extend_user_subscription(data: SubscriptionCreate):
|
||||
sub = await extend_subscription_by_user(data.user_id, data.months)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
|
||||
|
||||
|
||||
@router.get("/by-uuid/{sub_uuid}", response_model=Optional[SubscriptionInfo])
|
||||
async def get_subscription_uuid(sub_uuid: str):
|
||||
sub = await get_subscription_by_uuid(sub_uuid)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
|
||||
|
||||
|
||||
@router.patch("/{sub_uuid}", response_model=SubscriptionInfo)
|
||||
async def update_subscription_endpoint(sub_uuid: str, data: SubscriptionUpdate):
|
||||
sub = await update_subscription(sub_uuid, until=data.until, user_id=data.user_id, active=data.active, subscription_link=data.subscription_link, connect_link=data.connect_link)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
|
||||
|
||||
|
||||
@router.delete("/{sub_uuid}")
|
||||
async def delete_subscription_endpoint(sub_uuid: str):
|
||||
ok = await delete_subscription(sub_uuid)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[SubscriptionInfo])
|
||||
async def list_all_subscriptions():
|
||||
subs = await list_subscriptions()
|
||||
return [SubscriptionInfo(user_id=s.user_id, uuid=s.uuid, until=s.until) for s in subs]
|
||||
107
api/route/users.py
Normal file
107
api/route/users.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from api.db.user import create_user, get_user, get_user_by_telegram, update_user, delete_user, list_users
|
||||
from api.db.subscription import (
|
||||
create_subscription,
|
||||
get_subscription_by_user,
|
||||
update_subscription,
|
||||
delete_subscription,
|
||||
)
|
||||
from api.route.subscription import SubscriptionInfo
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
telegram_id: str
|
||||
name: Optional[str]
|
||||
subscription: Optional[SubscriptionInfo] = None
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
telegram_id: str
|
||||
name: Optional[str]
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: Optional[str]
|
||||
|
||||
|
||||
@router.post("/", response_model=UserOut)
|
||||
async def api_create_user(data: UserCreate):
|
||||
existing = await get_user_by_telegram(data.telegram_id)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already exists")
|
||||
u = await create_user(data.telegram_id, data.name)
|
||||
return UserOut(id=u.id, telegram_id=u.telegram_id, name=u.name)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserOut)
|
||||
async def api_get_user(user_id: str, expand: Optional[str] = None):
|
||||
u = await get_user(user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
out = UserOut(id=u.id, telegram_id=u.telegram_id, name=u.name)
|
||||
if expand == "subscription":
|
||||
sub = await get_subscription_by_user(u.id)
|
||||
if sub:
|
||||
out.subscription = SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{user_id}/subscription", response_model=SubscriptionInfo)
|
||||
async def api_create_user_subscription(user_id: str, data: dict):
|
||||
# expected keys: months, subscription_link (opt), connect_link (opt)
|
||||
months = int(data.get("months", 1))
|
||||
subscription_link = data.get("subscription_link")
|
||||
connect_link = data.get("connect_link")
|
||||
import uuid as _uuid
|
||||
sub_id = str(_uuid.uuid4())
|
||||
from datetime import datetime, timedelta
|
||||
until = datetime.now() + timedelta(days=30 * months)
|
||||
sub = await create_subscription(user_id, sub_id, until, subscription_link=subscription_link, connect_link=connect_link, active=True)
|
||||
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
|
||||
|
||||
|
||||
@router.patch("/{user_id}/subscription", response_model=SubscriptionInfo)
|
||||
async def api_update_user_subscription(user_id: str, data: dict):
|
||||
sub = await get_subscription_by_user(user_id)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found for user")
|
||||
updated = await update_subscription(sub.uuid, until=data.get("until"), user_id=data.get("user_id"), active=data.get("active"), subscription_link=data.get("subscription_link"), connect_link=data.get("connect_link"))
|
||||
return SubscriptionInfo(user_id=updated.user_id, uuid=updated.uuid, until=updated.until, active=updated.active, subscription_link=updated.subscription_link, connect_link=updated.connect_link)
|
||||
|
||||
|
||||
@router.delete("/{user_id}/subscription")
|
||||
async def api_delete_user_subscription(user_id: str):
|
||||
sub = await get_subscription_by_user(user_id)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found for user")
|
||||
ok = await delete_subscription(sub.uuid)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete subscription")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserOut])
|
||||
async def api_list_users():
|
||||
users = await list_users()
|
||||
return [UserOut(id=u.id, telegram_id=u.telegram_id, name=u.name) for u in users]
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut)
|
||||
async def api_update_user(user_id: str, data: UserUpdate):
|
||||
u = await update_user(user_id, name=data.name)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return UserOut(id=u.id, telegram_id=u.telegram_id, name=u.name)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def api_delete_user(user_id: str):
|
||||
ok = await delete_user(user_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"ok": True}
|
||||
Reference in New Issue
Block a user