harden telegram webapp production readiness

This commit is contained in:
VPN SaaS Dev
2026-05-12 19:14:21 +09:00
parent e75697f83e
commit 2ba2e88432
27 changed files with 931 additions and 155 deletions

View File

@@ -2,16 +2,23 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session
from app.models.car import Car
from app.models.user import User
from app.schemas.car import CarCreate, CarRead, CarUpdate
router = APIRouter(prefix="/cars", tags=["cars"])
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
async def create_car(payload: CarCreate, session: AsyncSession = Depends(get_session)) -> Car:
car = Car(**payload.model_dump())
async def create_car(
payload: CarCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Car:
data = payload.model_dump(exclude={"owner_id"})
car = Car(**data, owner_id=current_user.id)
session.add(car)
await session.commit()
await session.refresh(car)
@@ -19,28 +26,45 @@ async def create_car(payload: CarCreate, session: AsyncSession = Depends(get_ses
@router.get("", response_model=list[CarRead])
async def list_cars(owner_id: int, session: AsyncSession = Depends(get_session)) -> list[Car]:
async def list_cars(
owner_id: int | None = None,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[Car]:
if owner_id is not None and owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
result = await session.execute(
select(Car).where(Car.owner_id == owner_id).order_by(Car.created_at.desc())
select(Car).where(Car.owner_id == current_user.id).order_by(Car.created_at.desc())
)
return list(result.scalars())
@router.get("/{car_id}", response_model=CarRead)
async def get_car(car_id: int, session: AsyncSession = Depends(get_session)) -> Car:
async def get_car(
car_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Car:
car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return car
@router.patch("/{car_id}", response_model=CarRead)
async def update_car(
car_id: int, payload: CarUpdate, session: AsyncSession = Depends(get_session)
car_id: int,
payload: CarUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Car:
car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(car, field, value)
await session.commit()
@@ -49,9 +73,15 @@ async def update_car(
@router.delete("/{car_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_car(car_id: int, session: AsyncSession = Depends(get_session)) -> None:
async def delete_car(
car_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await session.delete(car)
await session.commit()

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

@@ -0,0 +1,94 @@
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.session import get_session
from app.models.car import Car
from app.models.user import User
from app.services.telegram_auth import verify_webapp_init_data
async def get_or_create_telegram_user(
session: AsyncSession,
*,
telegram_id: int,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
locale: str | None = None,
currency: str | None = None,
) -> User:
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
user = result.scalar_one_or_none()
payload = {
"telegram_id": telegram_id,
"username": str(telegram_id),
"first_name": first_name,
"last_name": last_name,
"locale": locale,
"currency": currency,
}
if user is None:
user = User(**{key: value for key, value in payload.items() if value is not None})
session.add(user)
else:
for field, value in payload.items():
if value is not None:
setattr(user, field, value)
await session.commit()
await session.refresh(user)
return user
def require_internal_api_token(token: str | None) -> None:
if not settings.internal_api_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Internal API token is not configured",
)
if not token or token != settings.internal_api_token:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
async def get_current_telegram_user(
session: Annotated[AsyncSession, Depends(get_session)],
x_telegram_init_data: Annotated[str | None, Header(alias="X-Telegram-Init-Data")] = None,
x_internal_api_token: Annotated[str | None, Header(alias="X-Internal-API-Token")] = None,
x_telegram_user_id: Annotated[int | None, Header(alias="X-Telegram-User-Id")] = None,
x_dev_telegram_id: Annotated[int | None, Header(alias="X-Dev-Telegram-Id")] = None,
) -> User:
if x_telegram_init_data:
user_data = verify_webapp_init_data(x_telegram_init_data, settings.bot_token)
return await get_or_create_telegram_user(
session,
telegram_id=int(user_data["id"]),
username=user_data.get("username"),
first_name=user_data.get("first_name"),
last_name=user_data.get("last_name"),
locale=user_data.get("language_code"),
)
if x_internal_api_token and x_telegram_user_id:
require_internal_api_token(x_internal_api_token)
return await get_or_create_telegram_user(session, telegram_id=x_telegram_user_id)
if settings.allow_dev_auth and not settings.is_production and x_dev_telegram_id:
return await get_or_create_telegram_user(session, telegram_id=x_dev_telegram_id)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram initData required")
async def get_owned_car(
car_id: int,
current_user: Annotated[User, Depends(get_current_telegram_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> Car:
car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return car

View File

@@ -1,41 +1,83 @@
from io import BytesIO
from datetime import date
from io import BytesIO
import matplotlib.pyplot as plt
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session
from app.models.car import Car
from app.models.expense import FuelEntry, ServiceEntry
from app.models.user import User
from app.schemas.expense import (
FuelEntryCreate,
FuelEntryRead,
FuelEntryUpdate,
OdometerPrediction,
OwnershipStats,
ServiceEntryCreate,
ServiceEntryRead,
ServiceEntryUpdate,
)
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
router = APIRouter(tags=["entries"])
async def ensure_car(session: AsyncSession, car_id: int) -> None:
if await session.get(Car, car_id) is None:
async def ensure_owned_car(session: AsyncSession, car_id: int, user: User) -> Car:
car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return car
async def ensure_entry_owner(
session: AsyncSession, entry: FuelEntry | ServiceEntry | None, user: User
) -> FuelEntry | ServiceEntry:
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
await ensure_owned_car(session, entry.car_id, user)
return entry
async def refresh_current_odometer(session: AsyncSession, car_id: int) -> None:
car = await session.get(Car, car_id)
if car is None:
return
fuel_result = await session.execute(
select(FuelEntry.odometer)
.where(FuelEntry.car_id == car_id)
.order_by(FuelEntry.odometer.desc())
.limit(1)
)
service_result = await session.execute(
select(ServiceEntry.odometer)
.where(ServiceEntry.car_id == car_id, ServiceEntry.odometer.is_not(None))
.order_by(ServiceEntry.odometer.desc())
.limit(1)
)
values = [
value
for value in (fuel_result.scalar_one_or_none(), service_result.scalar_one_or_none())
if value is not None
]
car.current_odometer = max(values) if values else None
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
async def create_fuel_entry(
payload: FuelEntryCreate, session: AsyncSession = Depends(get_session)
payload: FuelEntryCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> FuelEntry:
await ensure_car(session, payload.car_id)
car = await ensure_owned_car(session, payload.car_id, current_user)
entry = FuelEntry(**payload.model_dump())
session.add(entry)
car = await session.get(Car, payload.car_id)
if car and (car.current_odometer is None or payload.odometer > car.current_odometer):
if car.current_odometer is None or payload.odometer > car.current_odometer:
car.current_odometer = payload.odometer
await session.commit()
await session.refresh(entry)
@@ -47,30 +89,69 @@ async def list_fuel_entries(
car_id: int,
date_from: date | None = None,
date_to: date | None = None,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[FuelEntry]:
await ensure_owned_car(session, car_id, current_user)
limit = min(max(limit, 1), 200)
offset = max(offset, 0)
stmt = select(FuelEntry).where(FuelEntry.car_id == car_id)
if date_from:
stmt = stmt.where(FuelEntry.entry_date >= date_from)
if date_to:
stmt = stmt.where(FuelEntry.entry_date <= date_to)
result = await session.execute(
stmt.order_by(FuelEntry.entry_date.desc())
stmt.order_by(FuelEntry.entry_date.desc()).limit(limit).offset(offset)
)
return list(result.scalars())
@router.patch("/fuel/{entry_id}", response_model=FuelEntryRead)
async def update_fuel_entry(
entry_id: int,
payload: FuelEntryUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> FuelEntry:
entry = await ensure_entry_owner(session, await session.get(FuelEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(entry, field, value)
if payload.total_cost is None and (
payload.liters is not None or payload.price_per_liter is not None
):
entry.total_cost = entry.liters * entry.price_per_liter
await refresh_current_odometer(session, entry.car_id)
await session.commit()
await session.refresh(entry)
return entry
@router.delete("/fuel/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_fuel_entry(
entry_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
entry = await ensure_entry_owner(session, await session.get(FuelEntry, entry_id), current_user)
car_id = entry.car_id
await session.delete(entry)
await session.flush()
await refresh_current_odometer(session, car_id)
await session.commit()
@router.post("/service", response_model=ServiceEntryRead, status_code=status.HTTP_201_CREATED)
async def create_service_entry(
payload: ServiceEntryCreate, session: AsyncSession = Depends(get_session)
payload: ServiceEntryCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEntry:
await ensure_car(session, payload.car_id)
car = await ensure_owned_car(session, payload.car_id, current_user)
entry = ServiceEntry(**payload.model_dump())
session.add(entry)
car = await session.get(Car, payload.car_id)
if car and payload.odometer and (
car.current_odometer is None or payload.odometer > car.current_odometer
):
if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer):
car.current_odometer = payload.odometer
await session.commit()
await session.refresh(entry)
@@ -82,27 +163,64 @@ async def list_service_entries(
car_id: int,
date_from: date | None = None,
date_to: date | None = None,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceEntry]:
await ensure_owned_car(session, car_id, current_user)
limit = min(max(limit, 1), 200)
offset = max(offset, 0)
stmt = select(ServiceEntry).where(ServiceEntry.car_id == car_id)
if date_from:
stmt = stmt.where(ServiceEntry.entry_date >= date_from)
if date_to:
stmt = stmt.where(ServiceEntry.entry_date <= date_to)
result = await session.execute(
stmt.order_by(ServiceEntry.entry_date.desc())
stmt.order_by(ServiceEntry.entry_date.desc()).limit(limit).offset(offset)
)
return list(result.scalars())
@router.patch("/service/{entry_id}", response_model=ServiceEntryRead)
async def update_service_entry(
entry_id: int,
payload: ServiceEntryUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEntry:
entry = await ensure_entry_owner(session, await session.get(ServiceEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(entry, field, value)
await refresh_current_odometer(session, entry.car_id)
await session.commit()
await session.refresh(entry)
return entry
@router.delete("/service/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_service_entry(
entry_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
entry = await ensure_entry_owner(session, await session.get(ServiceEntry, entry_id), current_user)
car_id = entry.car_id
await session.delete(entry)
await session.flush()
await refresh_current_odometer(session, car_id)
await session.commit()
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
async def car_stats(
car_id: int,
date_from: date | None = None,
date_to: date | None = None,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> OwnershipStats:
await ensure_car(session, car_id)
await ensure_owned_car(session, car_id, current_user)
today = date.today()
period_from = date_from or today.replace(day=1)
period_to = date_to or today
@@ -110,14 +228,22 @@ async def car_stats(
@router.get("/cars/{car_id}/analytics", response_model=OdometerPrediction)
async def car_analytics(car_id: int, session: AsyncSession = Depends(get_session)) -> OdometerPrediction:
await ensure_car(session, car_id)
async def car_analytics(
car_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> OdometerPrediction:
await ensure_owned_car(session, car_id, current_user)
return await predict_odometer(session, car_id)
@router.get("/cars/{car_id}/charts/expenses.png")
async def expenses_chart(car_id: int, session: AsyncSession = Depends(get_session)) -> Response:
await ensure_car(session, car_id)
async def expenses_chart(
car_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Response:
await ensure_owned_car(session, car_id, current_user)
fuel_df = await dataframe_from_query(
session,
select(FuelEntry.entry_date.label("date"), FuelEntry.total_cost.label("cost")).where(

View File

@@ -1,9 +1,12 @@
import re
from decimal import Decimal
from fastapi import APIRouter, File, UploadFile
from fastapi import APIRouter, Depends, File, UploadFile
from pydantic import BaseModel
from app.api.deps import get_current_telegram_user
from app.models.user import User
router = APIRouter(prefix="/ocr", tags=["ocr"])
@@ -16,9 +19,18 @@ class ReceiptSuggestion(BaseModel):
message: str
@router.post("/fuel-receipt", response_model=ReceiptSuggestion)
async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
async def parse_text_receipt(
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
) -> ReceiptSuggestion:
content = await file.read()
content_type = (file.content_type or "").lower()
if content_type.startswith("image/") or content_type == "application/pdf":
return ReceiptSuggestion(
confidence=0,
message="OCR по фото/PDF пока не подключен. Загрузите текстовый чек или заполните поля вручную.",
)
text = " ".join(
[
file.filename or "",
@@ -54,13 +66,21 @@ async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
station=station,
confidence=round(confidence, 2) if numbers else 0,
message=(
"Распознал данные чека и заполнил форму. Проверь значения перед сохранением."
"Разобрал текст чека и заполнил форму. Проверь значения перед сохранением."
if numbers
else "Не удалось прочитать данные чека. Попробуй фото крупнее или заполни поля вручную."
else "Не удалось разобрать текст чека. Загрузите текстовый чек или заполните поля вручную."
),
)
@router.post("/fuel-receipt", response_model=ReceiptSuggestion, deprecated=True)
async def scan_fuel_receipt(
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
) -> ReceiptSuggestion:
return await parse_text_receipt(file, current_user)
def detect_station(text: str) -> str | None:
stations = {
"shell": "Shell",

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_internal_api_token
from app.db.session import get_session
from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage
from app.schemas.service_center import (
@@ -18,8 +19,11 @@ router = APIRouter(prefix="/service-centers", tags=["service-centers"])
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
async def create_service_center(
payload: ServiceCenterCreate, session: AsyncSession = Depends(get_session)
payload: ServiceCenterCreate,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> ServiceCenter:
require_internal_api_token(x_internal_api_token)
center = ServiceCenter(**payload.model_dump())
session.add(center)
await session.commit()
@@ -28,15 +32,22 @@ async def create_service_center(
@router.get("", response_model=list[ServiceCenterRead])
async def list_service_centers(session: AsyncSession = Depends(get_session)) -> list[ServiceCenter]:
async def list_service_centers(
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> list[ServiceCenter]:
require_internal_api_token(x_internal_api_token)
result = await session.execute(select(ServiceCenter).order_by(ServiceCenter.name))
return list(result.scalars())
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
async def link_car_to_service(
payload: CarServiceLinkCreate, session: AsyncSession = Depends(get_session)
payload: CarServiceLinkCreate,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> CarServiceLink:
require_internal_api_token(x_internal_api_token)
if await session.get(Car, payload.car_id) is None:
raise HTTPException(status_code=404, detail="Car not found")
if await session.get(ServiceCenter, payload.service_center_id) is None:
@@ -50,8 +61,11 @@ async def link_car_to_service(
@router.post("/inbox", response_model=ServiceInboxRead, status_code=status.HTTP_201_CREATED)
async def receive_service_message(
payload: ServiceInboxCreate, session: AsyncSession = Depends(get_session)
payload: ServiceInboxCreate,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> ServiceInboxMessage:
require_internal_api_token(x_internal_api_token)
service_center_id = payload.service_center_id
if not service_center_id and payload.source_chat_id:
result = await session.execute(

View File

@@ -1,9 +1,14 @@
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import (
get_current_telegram_user,
get_or_create_telegram_user,
require_internal_api_token,
)
from app.core.config import settings
from app.db.session import get_session
from app.models.car import Car
@@ -29,41 +34,14 @@ def username_from_telegram(telegram_id: int, username: str | None = None) -> str
return str(telegram_id) if not username else str(telegram_id)
async def upsert_telegram_user(
session: AsyncSession,
*,
telegram_id: int,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
locale: str | None = None,
currency: str | None = None,
) -> User:
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
user = result.scalar_one_or_none()
payload = {
"telegram_id": telegram_id,
"username": username_from_telegram(telegram_id, username),
"first_name": first_name,
"last_name": last_name,
"locale": locale,
"currency": currency,
}
if user is None:
user = User(**{key: value for key, value in payload.items() if value is not None})
session.add(user)
else:
for field, value in payload.items():
if value is not None:
setattr(user, field, value)
await session.commit()
await session.refresh(user)
return user
@router.post("", response_model=UserRead)
async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User:
return await upsert_telegram_user(session, **payload.model_dump())
async def upsert_user(
payload: UserUpsert,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> User:
require_internal_api_token(x_internal_api_token)
return await get_or_create_telegram_user(session, **payload.model_dump())
@router.get("/auth/config", response_model=AuthConfig)
@@ -71,6 +49,8 @@ async def auth_config() -> AuthConfig:
return AuthConfig(
bot_username=settings.bot_username or "seoulmate_officialbot",
vapid_public_key=settings.vapid_public_key or None,
app_env=settings.app_env,
allow_dev_auth=settings.allow_dev_auth and not settings.is_production,
)
@@ -80,7 +60,7 @@ async def webapp_auth(
) -> User:
user_data = verify_webapp_init_data(payload.init_data, settings.bot_token)
telegram_id = int(user_data["id"])
return await upsert_telegram_user(
return await get_or_create_telegram_user(
session,
telegram_id=telegram_id,
username=user_data.get("username"),
@@ -96,7 +76,7 @@ async def telegram_login(
) -> User:
values = verify_login_widget(payload.model_dump(), settings.bot_token)
telegram_id = int(values["id"])
return await upsert_telegram_user(
return await get_or_create_telegram_user(
session,
telegram_id=telegram_id,
username=values.get("username"),
@@ -105,21 +85,35 @@ async def telegram_login(
)
@router.get("/me", response_model=UserRead)
async def current_user_profile(current_user: User = Depends(get_current_telegram_user)) -> User:
return current_user
@router.get("/telegram/{telegram_id}", response_model=UserRead)
async def get_user_by_telegram_id(
telegram_id: int, session: AsyncSession = Depends(get_session)
telegram_id: int,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> User:
require_internal_api_token(x_internal_api_token)
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
return result.scalar_one()
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/{user_id}/preferences", response_model=UserRead)
async def update_preferences(
user_id: int, payload: UserPreferencesUpdate, session: AsyncSession = Depends(get_session)
user_id: int,
payload: UserPreferencesUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> User:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if current_user.id != user_id:
raise HTTPException(status_code=403, detail="Forbidden")
user = current_user
for field, value in payload.model_dump(exclude_none=True).items():
setattr(user, field, value)
await session.commit()
@@ -133,10 +127,10 @@ async def save_push_subscription(
payload: PushSubscriptionCreate,
request: Request,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if current_user.id != user_id:
raise HTTPException(status_code=403, detail="Forbidden")
result = await session.execute(
select(PushSubscription).where(
PushSubscription.user_id == user_id,
@@ -166,8 +160,15 @@ async def save_push_subscription(
async def due_reminders(
user_id: int,
days: int = 30,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ReminderRead]:
if current_user.id != user_id:
raise HTTPException(status_code=403, detail="Forbidden")
limit = min(max(limit, 1), 200)
offset = max(offset, 0)
today = date.today()
horizon = today + timedelta(days=max(1, min(days, 180)))
stmt = (
@@ -183,6 +184,8 @@ async def due_reminders(
)
)
.order_by(ServiceEntry.next_due_date.asc().nulls_last())
.limit(limit)
.offset(offset)
)
rows = (await session.execute(stmt)).all()
reminders: list[ReminderRead] = []