harden telegram webapp production readiness
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal 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
144
README.md
@@ -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-совместимость.
|
||||
|
||||
49
alembic/versions/202605120004_security_indexes.py
Normal file
49
alembic/versions/202605120004_security_indexes.py
Normal 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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
11
bot/main.py
11
bot/main.py
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
71
tests/conftest.py
Normal 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
30
tests/test_auth.py
Normal 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
89
tests/test_entries.py
Normal 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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}%`;
|
||||
|
||||
Reference in New Issue
Block a user