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] = []

View File

@@ -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:

View File

@@ -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 = [

View File

@@ -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=["*"],

View File

@@ -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

View File

@@ -29,7 +29,7 @@ class CarBase(BaseModel):
class CarCreate(CarBase):
owner_id: int
owner_id: int | None = None
class CarUpdate(BaseModel):

View File

@@ -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

View File

@@ -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):

View File

@@ -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()

View File

@@ -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: