harden telegram webapp production readiness
This commit is contained in:
@@ -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
94
app/api/deps.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user