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] = []
|
||||
|
||||
@@ -9,13 +9,37 @@ class Settings(BaseSettings):
|
||||
bot_username: str = ""
|
||||
api_base_url: str = "http://localhost:8000"
|
||||
webapp_url: str = "http://localhost:8000"
|
||||
public_webapp_url: str | None = None
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
app_env: str = "production"
|
||||
cors_origins: str = ""
|
||||
internal_api_token: str = ""
|
||||
vapid_public_key: str = ""
|
||||
allow_dev_auth: bool = False
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [item.strip().rstrip("/") for item in self.cors_origins.split(",") if item.strip()]
|
||||
|
||||
@property
|
||||
def effective_webapp_url(self) -> str:
|
||||
return (self.public_webapp_url or self.webapp_url).rstrip("/")
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() == "production"
|
||||
|
||||
def validate_webapp_url_for_telegram(self) -> None:
|
||||
url = self.effective_webapp_url
|
||||
if self.is_production and not url.startswith("https://"):
|
||||
raise RuntimeError("WEBAPP_URL/PUBLIC_WEBAPP_URL must be public HTTPS in production")
|
||||
forbidden = ("localhost", "127.0.0.1", "0.0.0.0", "http://")
|
||||
if self.is_production and any(item in url for item in forbidden):
|
||||
raise RuntimeError("Telegram Mini App URL must not use localhost, internal IP, or http://")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.models.expense import FuelEntry, ServiceEntry, ServiceType
|
||||
from app.models.user import User
|
||||
from app.services.catalog_data import CAR_CATALOG, CAR_TRIMS, COMMON_TRIMS, MAKE_COUNTRIES
|
||||
|
||||
|
||||
MOCK_PLATE_PREFIX = "MOCK"
|
||||
|
||||
MOCK_CARS = [
|
||||
|
||||
@@ -3,12 +3,16 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api import cars, catalog, entries, ocr, service_centers, users
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(title="Drivers Bot API", version="0.1.0")
|
||||
|
||||
dev_origins = ["http://localhost:8000", "http://127.0.0.1:8000"] if not settings.is_production else []
|
||||
cors_origins = settings.cors_origin_list or dev_origins
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import (
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
@@ -29,7 +29,7 @@ class CarBase(BaseModel):
|
||||
|
||||
|
||||
class CarCreate(CarBase):
|
||||
owner_id: int
|
||||
owner_id: int | None = None
|
||||
|
||||
|
||||
class CarUpdate(BaseModel):
|
||||
|
||||
@@ -28,6 +28,18 @@ class FuelEntryCreate(FuelEntryBase):
|
||||
car_id: int
|
||||
|
||||
|
||||
class FuelEntryUpdate(BaseModel):
|
||||
entry_date: date | None = None
|
||||
odometer: int | None = None
|
||||
liters: Decimal | None = None
|
||||
price_per_liter: Decimal | None = None
|
||||
total_cost: Decimal | None = None
|
||||
station: str | None = None
|
||||
fuel_brand: str | None = None
|
||||
is_full_tank: bool | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class FuelEntryRead(FuelEntryBase):
|
||||
id: int
|
||||
car_id: int
|
||||
@@ -54,6 +66,19 @@ class ServiceEntryCreate(ServiceEntryBase):
|
||||
car_id: int
|
||||
|
||||
|
||||
class ServiceEntryUpdate(BaseModel):
|
||||
entry_date: date | None = None
|
||||
odometer: int | None = None
|
||||
service_type: ServiceType | None = None
|
||||
title: str | None = None
|
||||
category: str | None = None
|
||||
vendor: str | None = None
|
||||
total_cost: Decimal | None = None
|
||||
next_due_date: date | None = None
|
||||
next_due_odometer: int | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ServiceEntryRead(ServiceEntryBase):
|
||||
id: int
|
||||
car_id: int
|
||||
|
||||
@@ -29,6 +29,8 @@ class TelegramLoginRequest(BaseModel):
|
||||
class AuthConfig(BaseModel):
|
||||
bot_username: str
|
||||
vapid_public_key: str | None = None
|
||||
app_env: str
|
||||
allow_dev_auth: bool = False
|
||||
|
||||
|
||||
class PushSubscriptionKeys(BaseModel):
|
||||
|
||||
@@ -38,7 +38,7 @@ async def get_ownership_stats(
|
||||
|
||||
distance_km = int(max_odo - min_odo) if min_odo is not None and max_odo is not None else 0
|
||||
total_cost = Decimal(fuel_cost) + Decimal(service_cost)
|
||||
avg_consumption = float(Decimal(liters) * Decimal(100) / distance_km) if distance_km else None
|
||||
avg_consumption = await full_tank_consumption(session, car_id, date_from, date_to)
|
||||
cost_per_km = float(total_cost / distance_km) if distance_km else None
|
||||
|
||||
return OwnershipStats(
|
||||
@@ -57,6 +57,48 @@ async def get_ownership_stats(
|
||||
)
|
||||
|
||||
|
||||
async def full_tank_consumption(
|
||||
session: AsyncSession, car_id: int, date_from: date, date_to: date
|
||||
) -> float | None:
|
||||
result = await session.execute(
|
||||
select(FuelEntry)
|
||||
.where(
|
||||
FuelEntry.car_id == car_id,
|
||||
FuelEntry.entry_date <= date_to,
|
||||
)
|
||||
.order_by(FuelEntry.entry_date.asc(), FuelEntry.odometer.asc(), FuelEntry.id.asc())
|
||||
)
|
||||
entries = list(result.scalars())
|
||||
full_indexes = [index for index, entry in enumerate(entries) if entry.is_full_tank]
|
||||
if len(full_indexes) < 2:
|
||||
return None
|
||||
|
||||
total_liters = Decimal("0")
|
||||
total_distance = 0
|
||||
previous_full_index = full_indexes[0]
|
||||
for current_full_index in full_indexes[1:]:
|
||||
previous = entries[previous_full_index]
|
||||
current = entries[current_full_index]
|
||||
if current.entry_date < date_from:
|
||||
previous_full_index = current_full_index
|
||||
continue
|
||||
distance = current.odometer - previous.odometer
|
||||
if distance <= 0:
|
||||
previous_full_index = current_full_index
|
||||
continue
|
||||
interval_liters = sum(
|
||||
Decimal(entry.liters) for entry in entries[previous_full_index + 1 : current_full_index + 1]
|
||||
)
|
||||
if interval_liters > 0:
|
||||
total_liters += interval_liters
|
||||
total_distance += distance
|
||||
previous_full_index = current_full_index
|
||||
|
||||
if total_distance <= 0 or total_liters <= 0:
|
||||
return None
|
||||
return float(total_liters * Decimal(100) / Decimal(total_distance))
|
||||
|
||||
|
||||
async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame:
|
||||
result = await session.execute(stmt)
|
||||
rows = result.mappings().all()
|
||||
|
||||
@@ -14,6 +14,8 @@ def _secret_key(bot_token: str, *, webapp: bool) -> bytes:
|
||||
|
||||
|
||||
def verify_webapp_init_data(init_data: str, bot_token: str, max_age_seconds: int = 86400) -> dict:
|
||||
if not bot_token:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="BOT_TOKEN is not configured")
|
||||
values = dict(parse_qsl(init_data, keep_blank_values=True))
|
||||
received_hash = values.pop("hash", "")
|
||||
if not received_hash:
|
||||
@@ -34,6 +36,8 @@ def verify_webapp_init_data(init_data: str, bot_token: str, max_age_seconds: int
|
||||
|
||||
|
||||
def verify_login_widget(payload: dict, bot_token: str, max_age_seconds: int = 86400) -> dict:
|
||||
if not bot_token:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="BOT_TOKEN is not configured")
|
||||
values = {key: value for key, value in payload.items() if value is not None}
|
||||
received_hash = str(values.pop("hash", ""))
|
||||
if not received_hash:
|
||||
|
||||
Reference in New Issue
Block a user