From 2ba2e88432fd00036d53c2787cea818180818332 Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Tue, 12 May 2026 19:14:21 +0900 Subject: [PATCH] harden telegram webapp production readiness --- .env.example | 17 ++ README.md | 144 +++++++++++---- .../versions/202605120004_security_indexes.py | 49 ++++++ app/api/cars.py | 44 ++++- app/api/deps.py | 94 ++++++++++ app/api/entries.py | 166 +++++++++++++++--- app/api/ocr.py | 30 +++- app/api/service_centers.py | 24 ++- app/api/users.py | 95 +++++----- app/core/config.py | 24 +++ app/db/seed.py | 3 +- app/main.py | 6 +- app/models/car.py | 12 +- app/schemas/car.py | 2 +- app/schemas/expense.py | 25 +++ app/schemas/user.py | 2 + app/services/calculations.py | 44 ++++- app/services/telegram_auth.py | 4 + bot/api_client.py | 26 ++- bot/main.py | 11 +- docker-compose.yml | 10 ++ pyproject.toml | 7 + tests/conftest.py | 71 ++++++++ tests/test_auth.py | 30 ++++ tests/test_entries.py | 89 ++++++++++ web/index.html | 16 +- web/static/app.js | 41 +++-- 27 files changed, 931 insertions(+), 155 deletions(-) create mode 100644 .env.example create mode 100644 alembic/versions/202605120004_security_indexes.py create mode 100644 app/api/deps.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_entries.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4afe472 --- /dev/null +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index f8ba3fc..6d5e851 100644 --- a/README.md +++ b/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-совместимость. diff --git a/alembic/versions/202605120004_security_indexes.py b/alembic/versions/202605120004_security_indexes.py new file mode 100644 index 0000000..2fb9105 --- /dev/null +++ b/alembic/versions/202605120004_security_indexes.py @@ -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") diff --git a/app/api/cars.py b/app/api/cars.py index 95b8b8e..a271079 100644 --- a/app/api/cars.py +++ b/app/api/cars.py @@ -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() diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..e0a547c --- /dev/null +++ b/app/api/deps.py @@ -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 diff --git a/app/api/entries.py b/app/api/entries.py index cb9d05b..89c0370 100644 --- a/app/api/entries.py +++ b/app/api/entries.py @@ -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( diff --git a/app/api/ocr.py b/app/api/ocr.py index e450600..38f74e5 100644 --- a/app/api/ocr.py +++ b/app/api/ocr.py @@ -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", diff --git a/app/api/service_centers.py b/app/api/service_centers.py index 04515df..559ea12 100644 --- a/app/api/service_centers.py +++ b/app/api/service_centers.py @@ -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( diff --git a/app/api/users.py b/app/api/users.py index 9964083..5009286 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -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] = [] diff --git a/app/core/config.py b/app/core/config.py index eb24eb3..e245c48 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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: diff --git a/app/db/seed.py b/app/db/seed.py index 1373630..30bd82a 100644 --- a/app/db/seed.py +++ b/app/db/seed.py @@ -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 = [ diff --git a/app/main.py b/app/main.py index ecfa3e7..6462d5a 100644 --- a/app/main.py +++ b/app/main.py @@ -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=["*"], diff --git a/app/models/car.py b/app/models/car.py index efafae1..8facaa2 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -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 diff --git a/app/schemas/car.py b/app/schemas/car.py index 866e0fa..126d384 100644 --- a/app/schemas/car.py +++ b/app/schemas/car.py @@ -29,7 +29,7 @@ class CarBase(BaseModel): class CarCreate(CarBase): - owner_id: int + owner_id: int | None = None class CarUpdate(BaseModel): diff --git a/app/schemas/expense.py b/app/schemas/expense.py index f41edc3..e292e78 100644 --- a/app/schemas/expense.py +++ b/app/schemas/expense.py @@ -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 diff --git a/app/schemas/user.py b/app/schemas/user.py index 8811d98..63df6bc 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -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): diff --git a/app/services/calculations.py b/app/services/calculations.py index de2b3ce..4c7e2bc 100644 --- a/app/services/calculations.py +++ b/app/services/calculations.py @@ -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() diff --git a/app/services/telegram_auth.py b/app/services/telegram_auth.py index daa67ba..8d9120b 100644 --- a/app/services/telegram_auth.py +++ b/app/services/telegram_auth.py @@ -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: diff --git a/bot/api_client.py b/bot/api_client.py index a03b640..958f3fd 100644 --- a/bot/api_client.py +++ b/bot/api_client.py @@ -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() diff --git a/bot/main.py b/bot/main.py index 71067ad..bc62a4e 100644 --- a/bot/main.py +++ b/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) diff --git a/docker-compose.yml b/docker-compose.yml index d962583..c962cb5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index e4950a2..3af7b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7947f57 --- /dev/null +++ b/tests/conftest.py @@ -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)} diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..9fdd4fb --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_entries.py b/tests/test_entries.py new file mode 100644 index 0000000..8f04f2a --- /dev/null +++ b/tests/test_entries.py @@ -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 diff --git a/web/index.html b/web/index.html index 0270ae6..4aa7956 100644 --- a/web/index.html +++ b/web/index.html @@ -16,9 +16,9 @@

Drivers

Гараж

-

Войди через Telegram, чтобы привязать гараж к твоему chat_id.

+

Это приложение открывается через Telegram-бота. Откройте Mini App из Telegram.

- +
@@ -102,8 +102,8 @@ ТО / ремонт @@ -220,7 +220,7 @@ - +