feat(proj): init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Deprecated Python API
|
||||
|
||||
Этот каталог больше не используется в runtime.
|
||||
|
||||
Актуальный API: `src/PleasePayMe.Api` (ASP.NET Core + PostgreSQL).
|
||||
Контракт HTTP (`/api/...`) сохранён для web и Telegram-бота.
|
||||
@@ -0,0 +1 @@
|
||||
# API package
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class ApiSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=BASE_DIR / ".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
database_path: Path = BASE_DIR / "data" / "budget.db"
|
||||
bot_token: str
|
||||
# Shared secret for bot (/api/auth/internal) and admin /api/budgets/*
|
||||
api_token: str | None = None
|
||||
jwt_secret: str | None = None
|
||||
jwt_ttl_seconds: int = 60 * 60 * 24 * 14
|
||||
telegram_auth_max_age_seconds: int = 60 * 60 * 24
|
||||
cors_origins: str = "*"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
@field_validator("bot_token", mode="before")
|
||||
@classmethod
|
||||
def require_bot_token(cls, value: object) -> object:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
raise ValueError("BOT_TOKEN is required for Telegram login")
|
||||
return value
|
||||
|
||||
@field_validator("api_token", "jwt_secret", mode="before")
|
||||
@classmethod
|
||||
def empty_as_none(cls, value: object) -> object:
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
@property
|
||||
def session_secret(self) -> str:
|
||||
return self.jwt_secret or f"ppm-jwt::{self.bot_token}"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
|
||||
|
||||
|
||||
settings = ApiSettings()
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
import jwt
|
||||
|
||||
from api.config import settings
|
||||
from api.tokens import decode_access_token
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthUser:
|
||||
user_id: int
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
|
||||
|
||||
def _extract_bearer(authorization: str | None) -> str | None:
|
||||
if not authorization:
|
||||
return None
|
||||
if authorization.lower().startswith("bearer "):
|
||||
return authorization[7:].strip()
|
||||
return None
|
||||
|
||||
|
||||
async def require_api_token(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_token: str | None = Header(default=None, alias="X-API-Token"),
|
||||
) -> None:
|
||||
if not settings.api_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Admin API_TOKEN is not configured",
|
||||
)
|
||||
token = (x_api_token or "").strip() or _extract_bearer(authorization)
|
||||
if not token or token != settings.api_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing API token",
|
||||
)
|
||||
|
||||
|
||||
async def require_user(
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> AuthUser:
|
||||
token = _extract_bearer(authorization)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization Bearer token required",
|
||||
)
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
user_id = int(payload["uid"])
|
||||
except (jwt.PyJWTError, KeyError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired session",
|
||||
) from exc
|
||||
|
||||
return AuthUser(
|
||||
user_id=user_id,
|
||||
first_name=payload.get("fn"),
|
||||
last_name=payload.get("ln"),
|
||||
username=payload.get("un"),
|
||||
)
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.config import settings
|
||||
from api.routes import router as api_router
|
||||
from bot.db.database import Database
|
||||
from bot.db.repository import BudgetRepository
|
||||
from bot.services.budget import BudgetService
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
db = Database(settings.database_path, read_only=False)
|
||||
await db.connect()
|
||||
app.state.db = db
|
||||
app.state.budget_service = BudgetService(BudgetRepository(db))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Please Pay Me API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "X-API-Token", "Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(api_router)
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
|
||||
from api.config import settings
|
||||
from api.deps import AuthUser, require_api_token, require_user
|
||||
from api.schemas import (
|
||||
AuthSessionOut,
|
||||
AuthUserOut,
|
||||
BudgetActiveIn,
|
||||
BudgetCreateIn,
|
||||
BudgetOut,
|
||||
BudgetStatusOut,
|
||||
BudgetUpdateIn,
|
||||
BudgetUpsertIn,
|
||||
BudgetsListOut,
|
||||
ExpenseCreateIn,
|
||||
ExpenseOut,
|
||||
ExpensesPageOut,
|
||||
InternalAuthIn,
|
||||
TelegramLoginIn,
|
||||
UndoExpenseOut,
|
||||
)
|
||||
from api.telegram_auth import TelegramAuthError, verify_telegram_login
|
||||
from api.tokens import create_access_token
|
||||
from bot.services.budget import BudgetService, BudgetStatus, PeriodExpensesPage
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
def _status_out(status_data: BudgetStatus) -> BudgetStatusOut:
|
||||
b = status_data.budget
|
||||
return BudgetStatusOut(
|
||||
budget=BudgetOut(
|
||||
id=b.id,
|
||||
user_id=b.user_id,
|
||||
name=b.name,
|
||||
total_amount=b.total_amount,
|
||||
start_date=b.start_date,
|
||||
end_date=b.end_date,
|
||||
currency=b.currency,
|
||||
is_active=b.is_active,
|
||||
),
|
||||
today=status_data.today,
|
||||
days_left=status_data.days_left,
|
||||
total_spent=status_data.total_spent,
|
||||
remaining=status_data.remaining,
|
||||
daily_limit=status_data.daily_limit,
|
||||
spent_today=status_data.spent_today,
|
||||
remaining_today=status_data.remaining_today,
|
||||
is_over_daily=status_data.is_over_daily,
|
||||
is_over_budget=status_data.is_over_budget,
|
||||
is_expired=status_data.is_expired,
|
||||
selected=status_data.selected,
|
||||
)
|
||||
|
||||
|
||||
def _page_out(page: PeriodExpensesPage) -> ExpensesPageOut:
|
||||
return ExpensesPageOut(
|
||||
page=page.page,
|
||||
total_pages=page.total_pages,
|
||||
total_count=page.total_count,
|
||||
total_sum=page.total_sum,
|
||||
page_size=page.page_size,
|
||||
budget_id=page.budget.id,
|
||||
items=[
|
||||
ExpenseOut(
|
||||
id=item.id,
|
||||
budget_id=item.budget_id,
|
||||
amount=item.amount,
|
||||
note=item.note,
|
||||
spent_at=item.spent_at,
|
||||
)
|
||||
for item in page.items
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _service(request: Request) -> BudgetService:
|
||||
return request.app.state.budget_service
|
||||
|
||||
|
||||
@router.post("/auth/telegram", response_model=AuthSessionOut)
|
||||
async def auth_telegram(body: TelegramLoginIn) -> AuthSessionOut:
|
||||
try:
|
||||
profile = verify_telegram_login(
|
||||
body.model_dump(),
|
||||
settings.bot_token,
|
||||
max_age_seconds=settings.telegram_auth_max_age_seconds,
|
||||
)
|
||||
except TelegramAuthError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
token = create_access_token(user_id=profile["id"], profile=profile)
|
||||
return AuthSessionOut(
|
||||
access_token=token,
|
||||
user=AuthUserOut(
|
||||
user_id=profile["id"],
|
||||
first_name=profile.get("first_name"),
|
||||
last_name=profile.get("last_name"),
|
||||
username=profile.get("username"),
|
||||
photo_url=profile.get("photo_url"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/auth/internal",
|
||||
response_model=AuthSessionOut,
|
||||
dependencies=[Depends(require_api_token)],
|
||||
)
|
||||
async def auth_internal(body: InternalAuthIn) -> AuthSessionOut:
|
||||
profile = {
|
||||
"id": body.user_id,
|
||||
"first_name": body.first_name,
|
||||
"last_name": body.last_name,
|
||||
"username": body.username,
|
||||
}
|
||||
token = create_access_token(user_id=body.user_id, profile=profile)
|
||||
return AuthSessionOut(
|
||||
access_token=token,
|
||||
user=AuthUserOut(
|
||||
user_id=body.user_id,
|
||||
first_name=body.first_name,
|
||||
last_name=body.last_name,
|
||||
username=body.username,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AuthUserOut)
|
||||
async def me(user: AuthUser = Depends(require_user)) -> AuthUserOut:
|
||||
return AuthUserOut(
|
||||
user_id=user.user_id,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
username=user.username,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/budgets", response_model=BudgetsListOut)
|
||||
async def my_budgets(
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
) -> BudgetsListOut:
|
||||
service = _service(request)
|
||||
items = await service.list_user_statuses(user.user_id)
|
||||
return BudgetsListOut(items=[_status_out(item) for item in items])
|
||||
|
||||
|
||||
@router.post("/me/budgets", response_model=BudgetStatusOut)
|
||||
async def create_my_budget(
|
||||
body: BudgetCreateIn,
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
) -> BudgetStatusOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
status_data = await service.create_budget(
|
||||
user_id=user.user_id,
|
||||
total_amount=body.total_amount,
|
||||
end_date=body.end_date,
|
||||
name=body.name,
|
||||
is_active=body.is_active,
|
||||
select=body.select,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return _status_out(status_data)
|
||||
|
||||
|
||||
@router.put("/me/budgets/{budget_id}", response_model=BudgetStatusOut)
|
||||
async def update_my_budget(
|
||||
budget_id: int,
|
||||
body: BudgetUpdateIn,
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
) -> BudgetStatusOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
status_data = await service.update_budget(
|
||||
user.user_id,
|
||||
budget_id,
|
||||
name=body.name,
|
||||
total_amount=body.total_amount,
|
||||
end_date=body.end_date,
|
||||
reset_expenses=body.reset_expenses,
|
||||
)
|
||||
except ValueError as exc:
|
||||
code = (
|
||||
status.HTTP_404_NOT_FOUND
|
||||
if "не найден" in str(exc).lower()
|
||||
else status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||
return _status_out(status_data)
|
||||
|
||||
|
||||
@router.patch("/me/budgets/{budget_id}/active", response_model=BudgetStatusOut)
|
||||
async def set_my_budget_active(
|
||||
budget_id: int,
|
||||
body: BudgetActiveIn,
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
) -> BudgetStatusOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
status_data = await service.set_budget_active(
|
||||
user.user_id,
|
||||
budget_id,
|
||||
body.is_active,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return _status_out(status_data)
|
||||
|
||||
|
||||
@router.post("/me/budgets/{budget_id}/select", response_model=BudgetStatusOut)
|
||||
async def select_my_budget(
|
||||
budget_id: int,
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
) -> BudgetStatusOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
status_data = await service.select_budget(user.user_id, budget_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return _status_out(status_data)
|
||||
|
||||
|
||||
@router.get("/me/budget", response_model=BudgetStatusOut)
|
||||
async def my_budget(
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
budget_id: int | None = Query(default=None),
|
||||
) -> BudgetStatusOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
status_data = await service.get_status(user.user_id, budget_id=budget_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return _status_out(status_data)
|
||||
|
||||
|
||||
@router.get("/me/expenses", response_model=ExpensesPageOut)
|
||||
async def my_expenses(
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
page: int = Query(default=0, ge=0),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
spent_at: date | None = Query(default=None),
|
||||
budget_id: int | None = Query(default=None),
|
||||
) -> ExpensesPageOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
if spent_at is not None:
|
||||
page_data = await service.get_expenses_on_date_page(
|
||||
user.user_id,
|
||||
spent_at,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
budget_id=budget_id,
|
||||
)
|
||||
else:
|
||||
page_data = await service.get_period_expenses_page(
|
||||
user.user_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
budget_id=budget_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return _page_out(page_data)
|
||||
|
||||
|
||||
@router.post("/me/expenses", response_model=BudgetStatusOut)
|
||||
async def create_my_expense(
|
||||
body: ExpenseCreateIn,
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
) -> BudgetStatusOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
status_data = await service.add_expense(
|
||||
user_id=user.user_id,
|
||||
amount=body.amount,
|
||||
note=body.note,
|
||||
spent_at=body.spent_at,
|
||||
budget_id=body.budget_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return _status_out(status_data)
|
||||
|
||||
|
||||
@router.delete("/me/expenses/last", response_model=UndoExpenseOut)
|
||||
async def undo_my_last_expense(
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
budget_id: int | None = Query(default=None),
|
||||
) -> UndoExpenseOut:
|
||||
service = _service(request)
|
||||
status_data, amount = await service.undo_last_expense(
|
||||
user.user_id,
|
||||
budget_id=budget_id,
|
||||
)
|
||||
if amount is None or status_data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Нечего отменять",
|
||||
)
|
||||
return UndoExpenseOut(deleted_amount=amount, status=_status_out(status_data))
|
||||
|
||||
|
||||
@router.put("/me/budget", response_model=BudgetStatusOut)
|
||||
async def upsert_my_budget(
|
||||
body: BudgetUpsertIn,
|
||||
request: Request,
|
||||
user: AuthUser = Depends(require_user),
|
||||
) -> BudgetStatusOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
status_data = await service.set_budget(
|
||||
user_id=user.user_id,
|
||||
total_amount=body.total_amount,
|
||||
end_date=body.end_date,
|
||||
reset_expenses=body.reset_expenses,
|
||||
name=body.name,
|
||||
budget_id=body.budget_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return _status_out(status_data)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/budgets",
|
||||
response_model=BudgetsListOut,
|
||||
dependencies=[Depends(require_api_token)],
|
||||
)
|
||||
async def list_budgets(request: Request) -> BudgetsListOut:
|
||||
service = _service(request)
|
||||
items = await service.list_budget_summaries()
|
||||
return BudgetsListOut(items=[_status_out(item) for item in items])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/budgets/{user_id}",
|
||||
response_model=BudgetStatusOut,
|
||||
dependencies=[Depends(require_api_token)],
|
||||
)
|
||||
async def get_budget(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
budget_id: int | None = Query(default=None),
|
||||
) -> BudgetStatusOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
status_data = await service.get_status(user_id, budget_id=budget_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return _status_out(status_data)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/budgets/{user_id}/expenses",
|
||||
response_model=ExpensesPageOut,
|
||||
dependencies=[Depends(require_api_token)],
|
||||
)
|
||||
async def list_expenses(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
page: int = Query(default=0, ge=0),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
spent_at: date | None = Query(default=None),
|
||||
budget_id: int | None = Query(default=None),
|
||||
) -> ExpensesPageOut:
|
||||
service = _service(request)
|
||||
try:
|
||||
if spent_at is not None:
|
||||
page_data = await service.get_expenses_on_date_page(
|
||||
user_id,
|
||||
spent_at,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
budget_id=budget_id,
|
||||
)
|
||||
else:
|
||||
page_data = await service.get_period_expenses_page(
|
||||
user_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
budget_id=budget_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return _page_out(page_data)
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BudgetOut(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
total_amount: float
|
||||
start_date: date
|
||||
end_date: date
|
||||
currency: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class BudgetStatusOut(BaseModel):
|
||||
budget: BudgetOut
|
||||
today: date
|
||||
days_left: int
|
||||
total_spent: float
|
||||
remaining: float
|
||||
daily_limit: float
|
||||
spent_today: float
|
||||
remaining_today: float
|
||||
is_over_daily: bool
|
||||
is_over_budget: bool
|
||||
is_expired: bool
|
||||
selected: bool = False
|
||||
|
||||
|
||||
class ExpenseOut(BaseModel):
|
||||
id: int
|
||||
budget_id: int
|
||||
amount: float
|
||||
note: str | None
|
||||
spent_at: date
|
||||
|
||||
|
||||
class ExpensesPageOut(BaseModel):
|
||||
page: int
|
||||
total_pages: int
|
||||
total_count: int
|
||||
total_sum: float
|
||||
page_size: int
|
||||
budget_id: int
|
||||
items: list[ExpenseOut]
|
||||
|
||||
|
||||
class BudgetsListOut(BaseModel):
|
||||
items: list[BudgetStatusOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TelegramLoginIn(BaseModel):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
photo_url: str | None = None
|
||||
auth_date: int
|
||||
hash: str
|
||||
|
||||
|
||||
class InternalAuthIn(BaseModel):
|
||||
"""Service auth for trusted clients (Telegram bot) acting as a user."""
|
||||
|
||||
user_id: int = Field(gt=0)
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class AuthUserOut(BaseModel):
|
||||
user_id: int
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
photo_url: str | None = None
|
||||
|
||||
|
||||
class AuthSessionOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: AuthUserOut
|
||||
|
||||
|
||||
class ExpenseCreateIn(BaseModel):
|
||||
amount: float = Field(gt=0)
|
||||
note: str | None = None
|
||||
spent_at: date | None = None
|
||||
budget_id: int | None = None
|
||||
|
||||
|
||||
class BudgetCreateIn(BaseModel):
|
||||
total_amount: float = Field(gt=0)
|
||||
end_date: date
|
||||
name: str = "Бюджет"
|
||||
is_active: bool = True
|
||||
select: bool = True
|
||||
|
||||
|
||||
class BudgetUpdateIn(BaseModel):
|
||||
total_amount: float | None = Field(default=None, gt=0)
|
||||
end_date: date | None = None
|
||||
name: str | None = None
|
||||
reset_expenses: bool = False
|
||||
|
||||
|
||||
class BudgetActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class BudgetUpsertIn(BaseModel):
|
||||
"""Legacy upsert against selected budget (or create if none)."""
|
||||
|
||||
total_amount: float = Field(gt=0)
|
||||
end_date: date
|
||||
reset_expenses: bool = True
|
||||
name: str | None = None
|
||||
budget_id: int | None = None
|
||||
|
||||
|
||||
class UndoExpenseOut(BaseModel):
|
||||
deleted_amount: float
|
||||
status: BudgetStatusOut
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TelegramAuthError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def verify_telegram_login(
|
||||
payload: dict[str, Any],
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int = 86400,
|
||||
) -> dict[str, Any]:
|
||||
"""Verify Telegram Login Widget data per Telegram docs."""
|
||||
received_hash = payload.get("hash")
|
||||
if not received_hash or not isinstance(received_hash, str):
|
||||
raise TelegramAuthError("Missing hash")
|
||||
|
||||
check_pairs: list[str] = []
|
||||
for key in sorted(payload.keys()):
|
||||
if key == "hash":
|
||||
continue
|
||||
value = payload[key]
|
||||
if value is None:
|
||||
continue
|
||||
check_pairs.append(f"{key}={value}")
|
||||
data_check_string = "\n".join(check_pairs)
|
||||
|
||||
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
|
||||
calculated = hmac.new(
|
||||
secret_key,
|
||||
data_check_string.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(calculated, received_hash):
|
||||
raise TelegramAuthError("Invalid Telegram login signature")
|
||||
|
||||
auth_date_raw = payload.get("auth_date")
|
||||
try:
|
||||
auth_date = int(auth_date_raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TelegramAuthError("Invalid auth_date") from exc
|
||||
|
||||
if max_age_seconds > 0 and time.time() - auth_date > max_age_seconds:
|
||||
raise TelegramAuthError("Telegram login data expired")
|
||||
|
||||
try:
|
||||
user_id = int(payload["id"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise TelegramAuthError("Missing Telegram user id") from exc
|
||||
|
||||
return {
|
||||
"id": user_id,
|
||||
"first_name": str(payload.get("first_name") or ""),
|
||||
"last_name": str(payload.get("last_name") or "") or None,
|
||||
"username": str(payload.get("username") or "") or None,
|
||||
"photo_url": str(payload.get("photo_url") or "") or None,
|
||||
"auth_date": auth_date,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from api.config import settings
|
||||
|
||||
|
||||
def create_access_token(*, user_id: int, profile: dict[str, Any]) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"uid": user_id,
|
||||
"fn": profile.get("first_name"),
|
||||
"ln": profile.get("last_name"),
|
||||
"un": profile.get("username"),
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.jwt_ttl_seconds),
|
||||
}
|
||||
return jwt.encode(payload, settings.session_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
return jwt.decode(token, settings.session_secret, algorithms=["HS256"])
|
||||
Reference in New Issue
Block a user