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

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
POSTGRES_DB=drivers
POSTGRES_USER=drivers
POSTGRES_PASSWORD=drivers
POSTGRES_PORT=5433
DATABASE_URL=postgresql+asyncpg://drivers:drivers@db:5432/drivers
BOT_TOKEN=change-me
BOT_USERNAME=your_bot_username
API_BASE_URL=http://api:8000
WEBAPP_URL=https://drivers.smartsoltech.kr
PUBLIC_WEBAPP_URL=https://drivers.smartsoltech.kr
CORS_ORIGINS=https://drivers.smartsoltech.kr,https://t.me
INTERNAL_API_TOKEN=change-this-long-random-token
APP_ENV=production
ALLOW_DEV_AUTH=false
APP_HOST=0.0.0.0
APP_PORT=8000
VAPID_PUBLIC_KEY=

144
README.md
View File

@@ -1,68 +1,138 @@
# Drivers Bot
Telegram mini app для учета расходов автовладельца: заправки, ремонты, обслуживание, жидкости, статистика стоимости владения и расхода топлива.
Telegram bot + Telegram Mini App для учета автомобилей, заправок, сервиса, жидкостей, напоминаний и стоимости владения.
## Состав
- `app/` - FastAPI сервис. Через него работают и бот, и HTML5 mini app.
- `bot/` - aiogram 3 бот, который регистрирует пользователя, открывает mini app и показывает быстрые команды.
- `web/` - HTML5 Telegram WebApp фронт.
- `app/` - FastAPI API, статика Mini App, бизнес-логика и Alembic.
- `bot/` - aiogram 3 бот, который открывает Mini App и работает с API через внутренний токен.
- `web/` - статический frontend Telegram WebApp.
- `alembic/` - миграции PostgreSQL.
- `tests/` - базовые security/API тесты.
## Основные таблицы
## Production Mini App
- `users` - пользователь Telegram.
- `cars` - автомобили пользователя.
- `fuel_entries` - заправки: дата, одометр, литры, цена, стоимость, АЗС.
- `service_entries` - обслуживание, ремонты, жидкости, шины, страховка, налоги и прочие расходы.
Для production Mini App должен открываться только по публичному HTTPS-домену. Для текущего проекта:
Связи: `users 1:N cars`, `cars 1:N fuel_entries`, `cars 1:N service_entries`.
```text
https://drivers.smartsoltech.kr
```
В BotFather нужно выполнить:
```text
/setdomain
@seoulmate_officialbot
drivers.smartsoltech.kr
```
Важно:
- в BotFather указывается домен без `https://`;
- `WEBAPP_URL` или `PUBLIC_WEBAPP_URL` в `.env` должны быть `https://drivers.smartsoltech.kr`;
- нельзя использовать `localhost`, `127.0.0.1`, внутренний IP или `http://` для Telegram Mini App в production;
- если появляется `Bot domain invalid`, сначала проверь `/setdomain` и значение `WEBAPP_URL` в контейнере бота.
## Production .env
```dotenv
POSTGRES_DB=drivers
POSTGRES_USER=drivers
POSTGRES_PASSWORD=change-this-db-password
POSTGRES_PORT=5433
DATABASE_URL=postgresql+asyncpg://drivers:change-this-db-password@db:5432/drivers
BOT_TOKEN=123456:telegram-token
BOT_USERNAME=seoulmate_officialbot
WEBAPP_URL=https://drivers.smartsoltech.kr
PUBLIC_WEBAPP_URL=https://drivers.smartsoltech.kr
API_BASE_URL=http://api:8000
CORS_ORIGINS=https://drivers.smartsoltech.kr,https://t.me
INTERNAL_API_TOKEN=change-this-long-random-token
APP_ENV=production
ALLOW_DEV_AUTH=false
VAPID_PUBLIC_KEY=
```
`BOT_TOKEN`, `DATABASE_URL`, `WEBAPP_URL`, `API_BASE_URL`, `CORS_ORIGINS`, `INTERNAL_API_TOKEN` читаются только из env. Секреты не хранятся в коде.
## Nginx
Пример reverse proxy:
```nginx
server {
listen 80;
server_name drivers.smartsoltech.kr;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name drivers.smartsoltech.kr;
ssl_certificate /etc/letsencrypt/live/drivers.smartsoltech.kr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/drivers.smartsoltech.kr/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
```
## Запуск
1. Создай `.env`:
```bash
cp .env.example .env
docker compose up -d --build
```
2. Заполни `BOT_TOKEN` и `WEBAPP_URL`. Для Telegram mini app `WEBAPP_URL` должен быть HTTPS URL, доступный Telegram.
API локально: `http://localhost:8000`.
3. Подними сервисы:
Локальные проверки:
```bash
docker compose up --build
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest -q
.venv/bin/ruff check app bot tests
docker compose build
docker compose up -d db api
curl http://127.0.0.1:8000/health
docker compose down
```
API будет доступен на `http://localhost:8000`, документация - `http://localhost:8000/docs`.
## Авторизация API
## Локальный запуск без Docker
Пользовательские endpoint-ы требуют Telegram WebApp `initData` в заголовке:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .
alembic upgrade head
uvicorn app.main:app --reload
```http
X-Telegram-Init-Data: query_id=...&user=...&auth_date=...&hash=...
```
В отдельном терминале:
Backend проверяет подпись Telegram, создает/обновляет пользователя и разрешает операции только с объектами владельца. Бот использует `INTERNAL_API_TOKEN` и `X-Telegram-User-Id`.
```bash
python -m bot.main
```
Публичное `/api/users` закрыто внутренним токеном. Для Mini App создание пользователя выполняется через `/api/users/webapp-auth`.
## API
## Основные endpoint-ы
Ключевые endpoint-ы:
- `GET /api/users/me`
- `POST /api/cars`, `GET /api/cars`, `GET/PATCH/DELETE /api/cars/{id}`
- `POST /api/fuel`, `GET /api/cars/{car_id}/fuel?limit=50&offset=0`
- `PATCH /api/fuel/{id}`, `DELETE /api/fuel/{id}`
- `POST /api/service`, `GET /api/cars/{car_id}/service?limit=50&offset=0`
- `PATCH /api/service/{id}`, `DELETE /api/service/{id}`
- `GET /api/cars/{car_id}/stats`
- `GET /api/users/{user_id}/reminders?limit=50&offset=0`
- `POST /api/ocr/parse-text-receipt`
- `POST /api/users` - создать или обновить пользователя Telegram.
- `POST /api/cars`, `GET /api/cars?owner_id=...` - автомобили.
- `POST /api/fuel`, `GET /api/cars/{car_id}/fuel` - заправки.
- `POST /api/service`, `GET /api/cars/{car_id}/service` - сервисные записи.
- `GET /api/cars/{car_id}/stats` - стоимость владения, топливо, пробег, расход л/100 км, цена 1 км.
- `GET /api/cars/{car_id}/charts/expenses.png` - график расходов через pandas/matplotlib.
Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру.
## Что дальше
## OCR
Практичные следующие шаги: авторизация WebApp через проверку `initData`, CRUD редактирование записей, напоминания по `next_due_date` и `next_due_odometer`, экспорт в CSV/XLSX, валюта и единицы измерения на уровне пользователя.
Настоящий OCR по фото/PDF пока не подключен. Endpoint `POST /api/ocr/parse-text-receipt` честно разбирает только текстовый чек. Старый `/api/ocr/fuel-receipt` оставлен как deprecated-совместимость.

View File

@@ -0,0 +1,49 @@
"""security indexes
Revision ID: 202605120004
Revises: 202605120003
Create Date: 2026-05-12
"""
from collections.abc import Sequence
from alembic import op
revision: str = "202605120004"
down_revision: str | None = "202605120003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_index(
"ix_fuel_entries_car_id_entry_date",
"fuel_entries",
["car_id", "entry_date"],
unique=False,
)
op.create_index(
"ix_service_entries_car_id_entry_date",
"service_entries",
["car_id", "entry_date"],
unique=False,
)
op.create_index(
"ix_service_entries_car_id_next_due_date",
"service_entries",
["car_id", "next_due_date"],
unique=False,
)
op.create_index(
"ix_service_entries_car_id_next_due_odometer",
"service_entries",
["car_id", "next_due_odometer"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_service_entries_car_id_next_due_odometer", table_name="service_entries")
op.drop_index("ix_service_entries_car_id_next_due_date", table_name="service_entries")
op.drop_index("ix_service_entries_car_id_entry_date", table_name="service_entries")
op.drop_index("ix_fuel_entries_car_id_entry_date", table_name="fuel_entries")

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:

View File

@@ -9,6 +9,12 @@ class ApiClient:
def __init__(self) -> None:
self.base_url = settings.api_base_url.rstrip("/")
def headers(self, telegram_id: int | None = None) -> dict[str, str]:
headers = {"X-Internal-API-Token": settings.internal_api_token}
if telegram_id is not None:
headers["X-Telegram-User-Id"] = str(telegram_id)
return headers
async def upsert_user(self, telegram_user: Any) -> dict[str, Any]:
payload = {
"telegram_id": telegram_user.id,
@@ -17,24 +23,30 @@ class ApiClient:
"last_name": telegram_user.last_name,
}
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.post("/api/users", json=payload)
response = await client.post("/api/users", json=payload, headers=self.headers())
response.raise_for_status()
return response.json()
async def list_cars(self, owner_id: int) -> list[dict[str, Any]]:
async def list_cars(self, owner_id: int, telegram_id: int) -> list[dict[str, Any]]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.get("/api/cars", params={"owner_id": owner_id})
response = await client.get(
"/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id)
)
response.raise_for_status()
return response.json()
async def create_car(self, owner_id: int, name: str) -> dict[str, Any]:
async def create_car(self, owner_id: int, name: str, telegram_id: int) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.post("/api/cars", json={"owner_id": owner_id, "name": name})
response = await client.post(
"/api/cars",
json={"owner_id": owner_id, "name": name},
headers=self.headers(telegram_id),
)
response.raise_for_status()
return response.json()
async def stats(self, car_id: int) -> dict[str, Any]:
async def stats(self, car_id: int, telegram_id: int) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.get(f"/api/cars/{car_id}/stats")
response = await client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
response.raise_for_status()
return response.json()

View File

@@ -25,7 +25,7 @@ api = ApiClient()
def main_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.webapp_url))],
[KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.effective_webapp_url))],
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")],
],
resize_keyboard=True,
@@ -50,7 +50,7 @@ async def add_car(message: Message, command: CommandObject) -> None:
if not name:
await message.answer("Напиши так: /add_car Toyota Camry")
return
car = await api.create_car(user["id"], name)
car = await api.create_car(user["id"], name, message.from_user.id)
await message.answer(f"Добавил авто: {car['name']}")
@@ -58,7 +58,7 @@ async def add_car(message: Message, command: CommandObject) -> None:
@dp.message(F.text == "Мои авто")
async def cars(message: Message) -> None:
user = await api.upsert_user(message.from_user)
items = await api.list_cars(user["id"])
items = await api.list_cars(user["id"], message.from_user.id)
if not items:
await message.answer("Автомобилей пока нет. Добавь через mini app или командой /add_car Название.")
return
@@ -72,7 +72,7 @@ async def cars(message: Message) -> None:
@dp.callback_query(F.data.startswith("stats:"))
async def show_stats(callback: CallbackQuery) -> None:
car_id = int(callback.data.split(":", 1)[1])
stats = await api.stats(car_id)
stats = await api.stats(car_id, callback.from_user.id)
consumption = stats["avg_consumption_l_per_100km"]
cost_per_km = stats["cost_per_km"]
await callback.message.answer(
@@ -106,6 +106,9 @@ async def help_message(message: Message) -> None:
async def main() -> None:
if not settings.bot_token:
raise RuntimeError("BOT_TOKEN is empty")
if not settings.internal_api_token:
raise RuntimeError("INTERNAL_API_TOKEN is empty")
settings.validate_webapp_url_for_telegram()
bot = Bot(settings.bot_token)
await dp.start_polling(bot)

View File

@@ -24,8 +24,14 @@ services:
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://drivers:drivers@db:5432/drivers}
BOT_TOKEN: ${BOT_TOKEN:-}
BOT_USERNAME: ${BOT_USERNAME:-}
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000}
PUBLIC_WEBAPP_URL: ${PUBLIC_WEBAPP_URL:-}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8000,http://127.0.0.1:8000}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
APP_ENV: ${APP_ENV:-development}
ALLOW_DEV_AUTH: ${ALLOW_DEV_AUTH:-false}
ports:
- "127.0.0.1:8000:8000"
depends_on:
@@ -40,8 +46,12 @@ services:
required: false
environment:
BOT_TOKEN: ${BOT_TOKEN:-}
BOT_USERNAME: ${BOT_USERNAME:-}
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000}
PUBLIC_WEBAPP_URL: ${PUBLIC_WEBAPP_URL:-}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
APP_ENV: ${APP_ENV:-development}
depends_on:
- api

View File

@@ -26,6 +26,9 @@ include = ["app*", "bot*"]
[project.optional-dependencies]
dev = [
"aiosqlite>=0.20,<1.0",
"pytest>=8.0,<9.0",
"pytest-asyncio>=0.23,<1.0",
"ruff>=0.4,<1.0",
]
@@ -35,3 +38,7 @@ target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["B008", "E501", "UP042"]
[tool.pytest.ini_options]
asyncio_mode = "auto"

71
tests/conftest.py Normal file
View File

@@ -0,0 +1,71 @@
import hashlib
import hmac
import json
import time
from collections.abc import AsyncGenerator
from urllib.parse import urlencode
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.api.deps import get_current_telegram_user
from app.core.config import settings
from app.db.base import Base
from app.db.session import get_session
from app.main import app
from app.models import car, expense, push, user # noqa: F401
TEST_BOT_TOKEN = "123456:test-token"
TEST_INTERNAL_TOKEN = "internal-test-token"
def make_init_data(telegram_id: int, first_name: str = "Test") -> str:
user_payload = json.dumps(
{"id": telegram_id, "first_name": first_name, "username": str(telegram_id)},
separators=(",", ":"),
)
values = {"auth_date": str(int(time.time())), "user": user_payload}
data_check_string = "\n".join(f"{key}={values[key]}" for key in sorted(values))
secret = hmac.new(b"WebAppData", TEST_BOT_TOKEN.encode(), hashlib.sha256).digest()
values["hash"] = hmac.new(secret, data_check_string.encode(), hashlib.sha256).hexdigest()
return urlencode(values)
@pytest.fixture(autouse=True)
def configure_settings() -> None:
settings.bot_token = TEST_BOT_TOKEN
settings.internal_api_token = TEST_INTERNAL_TOKEN
settings.app_env = "test"
settings.allow_dev_auth = False
yield
@pytest.fixture()
async def client() -> AsyncGenerator[AsyncClient, None]:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def override_session() -> AsyncGenerator:
async with session_factory() as session:
yield session
app.dependency_overrides[get_session] = override_session
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as test_client:
yield test_client
app.dependency_overrides.pop(get_session, None)
app.dependency_overrides.pop(get_current_telegram_user, None)
await engine.dispose()
@pytest.fixture()
def auth_headers() -> dict[str, str]:
return {"X-Telegram-Init-Data": make_init_data(1001)}
@pytest.fixture()
def other_auth_headers() -> dict[str, str]:
return {"X-Telegram-Init-Data": make_init_data(2002)}

30
tests/test_auth.py Normal file
View File

@@ -0,0 +1,30 @@
import pytest
from conftest import TEST_BOT_TOKEN, make_init_data
from app.core.config import Settings
from app.services.telegram_auth import verify_webapp_init_data
def test_telegram_init_data_auth() -> None:
values = verify_webapp_init_data(make_init_data(42), TEST_BOT_TOKEN)
assert values["id"] == 42
def test_cors_config_reads_csv() -> None:
settings = Settings(
bot_token="token",
cors_origins="https://drivers.smartsoltech.kr,https://t.me",
)
assert settings.cors_origin_list == ["https://drivers.smartsoltech.kr", "https://t.me"]
@pytest.mark.asyncio
async def test_user_cannot_get_foreign_car(client, auth_headers, other_auth_headers) -> None:
created = await client.post("/api/cars", headers=auth_headers, json={"name": "Owner car"})
car_id = created.json()["id"]
response = await client.get(f"/api/cars/{car_id}", headers=other_auth_headers)
assert response.status_code == 403

89
tests/test_entries.py Normal file
View File

@@ -0,0 +1,89 @@
import pytest
@pytest.mark.asyncio
async def test_user_cannot_add_fuel_to_foreign_car(client, auth_headers, other_auth_headers) -> None:
created = await client.post("/api/cars", headers=auth_headers, json={"name": "Owner car"})
car_id = created.json()["id"]
response = await client.post(
"/api/fuel",
headers=other_auth_headers,
json={
"car_id": car_id,
"entry_date": "2026-05-12",
"odometer": 1000,
"liters": 30,
"price_per_liter": 2,
},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_fuel_crud(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Fuel car"})).json()
created = await client.post(
"/api/fuel",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-05-12",
"odometer": 1000,
"liters": 30,
"price_per_liter": 2,
},
)
assert created.status_code == 201
entry_id = created.json()["id"]
patched = await client.patch(
f"/api/fuel/{entry_id}",
headers=auth_headers,
json={"liters": 35, "price_per_liter": 3},
)
assert patched.status_code == 200
assert patched.json()["total_cost"] == "105.00"
deleted = await client.delete(f"/api/fuel/{entry_id}", headers=auth_headers)
assert deleted.status_code == 204
@pytest.mark.asyncio
async def test_service_crud(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Service car"})).json()
created = await client.post(
"/api/service",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-05-12",
"service_type": "maintenance",
"title": "Oil",
"total_cost": 100,
},
)
assert created.status_code == 201
entry_id = created.json()["id"]
patched = await client.patch(
f"/api/service/{entry_id}",
headers=auth_headers,
json={"title": "Oil and filter", "next_due_odometer": 2000},
)
assert patched.status_code == 200
assert patched.json()["title"] == "Oil and filter"
deleted = await client.delete(f"/api/service/{entry_id}", headers=auth_headers)
assert deleted.status_code == 204
@pytest.mark.asyncio
async def test_stats_do_not_fail_with_insufficient_data(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Stats car"})).json()
response = await client.get(f"/api/cars/{car['id']}/stats", headers=auth_headers)
assert response.status_code == 200
assert response.json()["avg_consumption_l_per_100km"] is None

View File

@@ -16,9 +16,9 @@
<div class="auth-panel">
<p class="eyebrow">Drivers</p>
<h1>Гараж</h1>
<p>Войди через Telegram, чтобы привязать гараж к твоему chat_id.</p>
<p id="authMessage">Это приложение открывается через Telegram-бота. Откройте Mini App из Telegram.</p>
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" target="_blank" rel="noreferrer">Войти через Telegram</a>
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" target="_blank" rel="noreferrer">Открыть бота</a>
</div>
</div>
<main class="shell">
@@ -102,8 +102,8 @@
<strong>ТО / ремонт</strong>
</button>
<button class="action-card" data-action="scan">
<span>Скан чека</span>
<strong>OCR</strong>
<span>Разбор чека</span>
<strong>Текст</strong>
</button>
</section>
@@ -220,7 +220,7 @@
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button>
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
<button class="menu-row" id="openNotificationsBtn">Уведомления</button>
<button class="menu-row" id="openScanBtn">Сканировать чек</button>
<button class="menu-row" id="openScanBtn">Разобрать чек</button>
<section class="drawer-section hidden" id="settingsSection">
<h2>Настройки</h2>
@@ -357,7 +357,7 @@
<div class="scan-modal hidden" id="scanModal">
<div class="scan-panel">
<div class="section-head">
<h2>Скан чека</h2>
<h2>Разбор чека</h2>
<button class="icon-btn" id="closeScanBtn" aria-label="Закрыть">×</button>
</div>
<form id="ocrForm" class="scan-form">
@@ -368,9 +368,9 @@
<input id="receiptCameraInput" class="hidden-file" type="file" accept="image/*" capture="environment" />
<input id="receiptFileInput" class="hidden-file" type="file" accept="image/*,.pdf,.txt" />
<div id="receiptFileName" class="file-hint">Файл не выбран</div>
<button type="submit">Распознать</button>
<button type="submit">Разобрать текст</button>
</form>
<div id="ocrResult" class="tip-card">После распознавания поля заправки заполнятся автоматически.</div>
<div id="ocrResult" class="tip-card">Сейчас разбираем текстовые чеки. OCR по фото будет подключен отдельно.</div>
</div>
</div>

View File

@@ -408,8 +408,13 @@ function formData(form) {
}
async function api(path, options = {}) {
const headers = { "Content-Type": "application/json", ...(options.headers || {}) };
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
}
const response = await fetch(`/api${path}`, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
headers,
...options,
});
if (!response.ok) {
@@ -497,13 +502,14 @@ async function ensureUser() {
hideAuthOverlay();
return;
}
const stored = localStorage.getItem("driversUser");
if (stored) {
state.user = JSON.parse(stored);
if (state.authConfig?.allow_dev_auth) {
const devId = localStorage.getItem("driversDevTelegramId") || "1";
localStorage.setItem("driversDevTelegramId", devId);
state.user = await api("/users/me");
hideAuthOverlay();
return;
}
await showTelegramLogin();
showTelegramOpenHint();
throw new Error("Требуется вход через Telegram");
}
@@ -512,22 +518,33 @@ function hideAuthOverlay() {
document.body.classList.remove("auth-required");
}
async function showTelegramLogin() {
function showTelegramOpenHint() {
const overlay = document.querySelector("#authOverlay");
const slot = document.querySelector("#telegramLoginSlot");
const link = document.querySelector("#telegramLoginLink");
const message = document.querySelector("#authMessage");
overlay?.classList.remove("hidden");
document.body.classList.add("auth-required");
if (!slot || slot.dataset.ready) return;
const botUsername = state.authConfig?.bot_username;
if (message) {
message.textContent = "Это приложение открывается через Telegram-бота. Откройте Mini App из Telegram.";
}
if (slot) slot.textContent = "";
if (!botUsername) {
slot.textContent = "Telegram Login временно недоступен";
return;
}
if (link) {
link.href = `https://t.me/${botUsername}?start=web_login`;
link.href = `https://t.me/${botUsername}`;
link.classList.remove("hidden");
}
}
async function showTelegramLogin() {
showTelegramOpenHint();
const slot = document.querySelector("#telegramLoginSlot");
if (!slot || slot.dataset.ready) return;
const botUsername = state.authConfig?.bot_username;
if (!botUsername) return;
window.onTelegramAuth = async (user) => {
state.user = await api("/users/telegram-login", {
method: "POST",
@@ -1424,7 +1441,11 @@ document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
await runAction(formButton, "Распознаю чек...", async () => {
const payload = new FormData();
payload.append("file", file);
const response = await fetch("/api/ocr/fuel-receipt", { method: "POST", body: payload });
const response = await fetch("/api/ocr/parse-text-receipt", {
method: "POST",
headers: tg?.initData ? { "X-Telegram-Init-Data": tg.initData } : {},
body: payload,
});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`;