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
|
# Drivers Bot
|
||||||
|
|
||||||
Telegram mini app для учета расходов автовладельца: заправки, ремонты, обслуживание, жидкости, статистика стоимости владения и расхода топлива.
|
Telegram bot + Telegram Mini App для учета автомобилей, заправок, сервиса, жидкостей, напоминаний и стоимости владения.
|
||||||
|
|
||||||
## Состав
|
## Состав
|
||||||
|
|
||||||
- `app/` - FastAPI сервис. Через него работают и бот, и HTML5 mini app.
|
- `app/` - FastAPI API, статика Mini App, бизнес-логика и Alembic.
|
||||||
- `bot/` - aiogram 3 бот, который регистрирует пользователя, открывает mini app и показывает быстрые команды.
|
- `bot/` - aiogram 3 бот, который открывает Mini App и работает с API через внутренний токен.
|
||||||
- `web/` - HTML5 Telegram WebApp фронт.
|
- `web/` - статический frontend Telegram WebApp.
|
||||||
- `alembic/` - миграции PostgreSQL.
|
- `alembic/` - миграции PostgreSQL.
|
||||||
|
- `tests/` - базовые security/API тесты.
|
||||||
|
|
||||||
## Основные таблицы
|
## Production Mini App
|
||||||
|
|
||||||
- `users` - пользователь Telegram.
|
Для production Mini App должен открываться только по публичному HTTPS-домену. Для текущего проекта:
|
||||||
- `cars` - автомобили пользователя.
|
|
||||||
- `fuel_entries` - заправки: дата, одометр, литры, цена, стоимость, АЗС.
|
|
||||||
- `service_entries` - обслуживание, ремонты, жидкости, шины, страховка, налоги и прочие расходы.
|
|
||||||
|
|
||||||
Связи: `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
|
```bash
|
||||||
cp .env.example .env
|
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
|
```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
|
```http
|
||||||
python -m venv .venv
|
X-Telegram-Init-Data: query_id=...&user=...&auth_date=...&hash=...
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -e .
|
|
||||||
alembic upgrade head
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
В отдельном терминале:
|
Backend проверяет подпись Telegram, создает/обновляет пользователя и разрешает операции только с объектами владельца. Бот использует `INTERNAL_API_TOKEN` и `X-Telegram-User-Id`.
|
||||||
|
|
||||||
```bash
|
Публичное `/api/users` закрыто внутренним токеном. Для Mini App создание пользователя выполняется через `/api/users/webapp-auth`.
|
||||||
python -m bot.main
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## Что дальше
|
## 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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_telegram_user
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import Car
|
from app.models.car import Car
|
||||||
|
from app.models.user import User
|
||||||
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/cars", tags=["cars"])
|
router = APIRouter(prefix="/cars", tags=["cars"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_car(payload: CarCreate, session: AsyncSession = Depends(get_session)) -> Car:
|
async def create_car(
|
||||||
car = Car(**payload.model_dump())
|
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)
|
session.add(car)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(car)
|
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])
|
@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(
|
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())
|
return list(result.scalars())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{car_id}", response_model=CarRead)
|
@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)
|
car = await session.get(Car, car_id)
|
||||||
if car is None:
|
if car is None:
|
||||||
raise HTTPException(status_code=404, detail="Car not found")
|
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
|
return car
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{car_id}", response_model=CarRead)
|
@router.patch("/{car_id}", response_model=CarRead)
|
||||||
async def update_car(
|
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:
|
||||||
car = await session.get(Car, car_id)
|
car = await session.get(Car, car_id)
|
||||||
if car is None:
|
if car is None:
|
||||||
raise HTTPException(status_code=404, detail="Car not found")
|
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():
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
setattr(car, field, value)
|
setattr(car, field, value)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -49,9 +73,15 @@ async def update_car(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{car_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@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)
|
car = await session.get(Car, car_id)
|
||||||
if car is None:
|
if car is None:
|
||||||
raise HTTPException(status_code=404, detail="Car not found")
|
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.delete(car)
|
||||||
await session.commit()
|
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 datetime import date
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_telegram_user
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import Car
|
from app.models.car import Car
|
||||||
from app.models.expense import FuelEntry, ServiceEntry
|
from app.models.expense import FuelEntry, ServiceEntry
|
||||||
|
from app.models.user import User
|
||||||
from app.schemas.expense import (
|
from app.schemas.expense import (
|
||||||
FuelEntryCreate,
|
FuelEntryCreate,
|
||||||
FuelEntryRead,
|
FuelEntryRead,
|
||||||
|
FuelEntryUpdate,
|
||||||
OdometerPrediction,
|
OdometerPrediction,
|
||||||
OwnershipStats,
|
OwnershipStats,
|
||||||
ServiceEntryCreate,
|
ServiceEntryCreate,
|
||||||
ServiceEntryRead,
|
ServiceEntryRead,
|
||||||
|
ServiceEntryUpdate,
|
||||||
)
|
)
|
||||||
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
|
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
|
||||||
|
|
||||||
router = APIRouter(tags=["entries"])
|
router = APIRouter(tags=["entries"])
|
||||||
|
|
||||||
|
|
||||||
async def ensure_car(session: AsyncSession, car_id: int) -> None:
|
async def ensure_owned_car(session: AsyncSession, car_id: int, user: User) -> Car:
|
||||||
if await session.get(Car, car_id) is None:
|
car = await session.get(Car, car_id)
|
||||||
|
if car is None:
|
||||||
raise HTTPException(status_code=404, detail="Car not found")
|
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)
|
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_fuel_entry(
|
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:
|
) -> FuelEntry:
|
||||||
await ensure_car(session, payload.car_id)
|
car = await ensure_owned_car(session, payload.car_id, current_user)
|
||||||
entry = FuelEntry(**payload.model_dump())
|
entry = FuelEntry(**payload.model_dump())
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
car = await session.get(Car, payload.car_id)
|
if car.current_odometer is None or payload.odometer > car.current_odometer:
|
||||||
if car and (car.current_odometer is None or payload.odometer > car.current_odometer):
|
|
||||||
car.current_odometer = payload.odometer
|
car.current_odometer = payload.odometer
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(entry)
|
await session.refresh(entry)
|
||||||
@@ -47,30 +89,69 @@ async def list_fuel_entries(
|
|||||||
car_id: int,
|
car_id: int,
|
||||||
date_from: date | None = None,
|
date_from: date | None = None,
|
||||||
date_to: date | None = None,
|
date_to: date | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
) -> list[FuelEntry]:
|
) -> 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)
|
stmt = select(FuelEntry).where(FuelEntry.car_id == car_id)
|
||||||
if date_from:
|
if date_from:
|
||||||
stmt = stmt.where(FuelEntry.entry_date >= date_from)
|
stmt = stmt.where(FuelEntry.entry_date >= date_from)
|
||||||
if date_to:
|
if date_to:
|
||||||
stmt = stmt.where(FuelEntry.entry_date <= date_to)
|
stmt = stmt.where(FuelEntry.entry_date <= date_to)
|
||||||
result = await session.execute(
|
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())
|
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)
|
@router.post("/service", response_model=ServiceEntryRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_service_entry(
|
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:
|
) -> ServiceEntry:
|
||||||
await ensure_car(session, payload.car_id)
|
car = await ensure_owned_car(session, payload.car_id, current_user)
|
||||||
entry = ServiceEntry(**payload.model_dump())
|
entry = ServiceEntry(**payload.model_dump())
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
car = await session.get(Car, payload.car_id)
|
if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer):
|
||||||
if car and payload.odometer and (
|
|
||||||
car.current_odometer is None or payload.odometer > car.current_odometer
|
|
||||||
):
|
|
||||||
car.current_odometer = payload.odometer
|
car.current_odometer = payload.odometer
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(entry)
|
await session.refresh(entry)
|
||||||
@@ -82,27 +163,64 @@ async def list_service_entries(
|
|||||||
car_id: int,
|
car_id: int,
|
||||||
date_from: date | None = None,
|
date_from: date | None = None,
|
||||||
date_to: date | None = None,
|
date_to: date | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
) -> list[ServiceEntry]:
|
) -> 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)
|
stmt = select(ServiceEntry).where(ServiceEntry.car_id == car_id)
|
||||||
if date_from:
|
if date_from:
|
||||||
stmt = stmt.where(ServiceEntry.entry_date >= date_from)
|
stmt = stmt.where(ServiceEntry.entry_date >= date_from)
|
||||||
if date_to:
|
if date_to:
|
||||||
stmt = stmt.where(ServiceEntry.entry_date <= date_to)
|
stmt = stmt.where(ServiceEntry.entry_date <= date_to)
|
||||||
result = await session.execute(
|
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())
|
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)
|
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
|
||||||
async def car_stats(
|
async def car_stats(
|
||||||
car_id: int,
|
car_id: int,
|
||||||
date_from: date | None = None,
|
date_from: date | None = None,
|
||||||
date_to: date | None = None,
|
date_to: date | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
) -> OwnershipStats:
|
) -> OwnershipStats:
|
||||||
await ensure_car(session, car_id)
|
await ensure_owned_car(session, car_id, current_user)
|
||||||
today = date.today()
|
today = date.today()
|
||||||
period_from = date_from or today.replace(day=1)
|
period_from = date_from or today.replace(day=1)
|
||||||
period_to = date_to or today
|
period_to = date_to or today
|
||||||
@@ -110,14 +228,22 @@ async def car_stats(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/cars/{car_id}/analytics", response_model=OdometerPrediction)
|
@router.get("/cars/{car_id}/analytics", response_model=OdometerPrediction)
|
||||||
async def car_analytics(car_id: int, session: AsyncSession = Depends(get_session)) -> OdometerPrediction:
|
async def car_analytics(
|
||||||
await ensure_car(session, car_id)
|
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)
|
return await predict_odometer(session, car_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cars/{car_id}/charts/expenses.png")
|
@router.get("/cars/{car_id}/charts/expenses.png")
|
||||||
async def expenses_chart(car_id: int, session: AsyncSession = Depends(get_session)) -> Response:
|
async def expenses_chart(
|
||||||
await ensure_car(session, car_id)
|
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(
|
fuel_df = await dataframe_from_query(
|
||||||
session,
|
session,
|
||||||
select(FuelEntry.entry_date.label("date"), FuelEntry.total_cost.label("cost")).where(
|
select(FuelEntry.entry_date.label("date"), FuelEntry.total_cost.label("cost")).where(
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import re
|
import re
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, File, UploadFile
|
from fastapi import APIRouter, Depends, File, UploadFile
|
||||||
from pydantic import BaseModel
|
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"])
|
router = APIRouter(prefix="/ocr", tags=["ocr"])
|
||||||
|
|
||||||
|
|
||||||
@@ -16,9 +19,18 @@ class ReceiptSuggestion(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/fuel-receipt", response_model=ReceiptSuggestion)
|
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
||||||
async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
|
async def parse_text_receipt(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> ReceiptSuggestion:
|
||||||
content = await file.read()
|
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(
|
text = " ".join(
|
||||||
[
|
[
|
||||||
file.filename or "",
|
file.filename or "",
|
||||||
@@ -54,13 +66,21 @@ async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
|
|||||||
station=station,
|
station=station,
|
||||||
confidence=round(confidence, 2) if numbers else 0,
|
confidence=round(confidence, 2) if numbers else 0,
|
||||||
message=(
|
message=(
|
||||||
"Распознал данные чека и заполнил форму. Проверь значения перед сохранением."
|
"Разобрал текст чека и заполнил форму. Проверь значения перед сохранением."
|
||||||
if numbers
|
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:
|
def detect_station(text: str) -> str | None:
|
||||||
stations = {
|
stations = {
|
||||||
"shell": "Shell",
|
"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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import require_internal_api_token
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage
|
from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage
|
||||||
from app.schemas.service_center import (
|
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)
|
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_service_center(
|
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:
|
) -> ServiceCenter:
|
||||||
|
require_internal_api_token(x_internal_api_token)
|
||||||
center = ServiceCenter(**payload.model_dump())
|
center = ServiceCenter(**payload.model_dump())
|
||||||
session.add(center)
|
session.add(center)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -28,15 +32,22 @@ async def create_service_center(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ServiceCenterRead])
|
@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))
|
result = await session.execute(select(ServiceCenter).order_by(ServiceCenter.name))
|
||||||
return list(result.scalars())
|
return list(result.scalars())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def link_car_to_service(
|
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:
|
) -> CarServiceLink:
|
||||||
|
require_internal_api_token(x_internal_api_token)
|
||||||
if await session.get(Car, payload.car_id) is None:
|
if await session.get(Car, payload.car_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Car not found")
|
raise HTTPException(status_code=404, detail="Car not found")
|
||||||
if await session.get(ServiceCenter, payload.service_center_id) is None:
|
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)
|
@router.post("/inbox", response_model=ServiceInboxRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def receive_service_message(
|
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:
|
) -> ServiceInboxMessage:
|
||||||
|
require_internal_api_token(x_internal_api_token)
|
||||||
service_center_id = payload.service_center_id
|
service_center_id = payload.service_center_id
|
||||||
if not service_center_id and payload.source_chat_id:
|
if not service_center_id and payload.source_chat_id:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
from datetime import date, timedelta
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.core.config import settings
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import Car
|
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)
|
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)
|
@router.post("", response_model=UserRead)
|
||||||
async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User:
|
async def upsert_user(
|
||||||
return await upsert_telegram_user(session, **payload.model_dump())
|
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)
|
@router.get("/auth/config", response_model=AuthConfig)
|
||||||
@@ -71,6 +49,8 @@ async def auth_config() -> AuthConfig:
|
|||||||
return AuthConfig(
|
return AuthConfig(
|
||||||
bot_username=settings.bot_username or "seoulmate_officialbot",
|
bot_username=settings.bot_username or "seoulmate_officialbot",
|
||||||
vapid_public_key=settings.vapid_public_key or None,
|
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:
|
||||||
user_data = verify_webapp_init_data(payload.init_data, settings.bot_token)
|
user_data = verify_webapp_init_data(payload.init_data, settings.bot_token)
|
||||||
telegram_id = int(user_data["id"])
|
telegram_id = int(user_data["id"])
|
||||||
return await upsert_telegram_user(
|
return await get_or_create_telegram_user(
|
||||||
session,
|
session,
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
username=user_data.get("username"),
|
username=user_data.get("username"),
|
||||||
@@ -96,7 +76,7 @@ async def telegram_login(
|
|||||||
) -> User:
|
) -> User:
|
||||||
values = verify_login_widget(payload.model_dump(), settings.bot_token)
|
values = verify_login_widget(payload.model_dump(), settings.bot_token)
|
||||||
telegram_id = int(values["id"])
|
telegram_id = int(values["id"])
|
||||||
return await upsert_telegram_user(
|
return await get_or_create_telegram_user(
|
||||||
session,
|
session,
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
username=values.get("username"),
|
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)
|
@router.get("/telegram/{telegram_id}", response_model=UserRead)
|
||||||
async def get_user_by_telegram_id(
|
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:
|
) -> User:
|
||||||
|
require_internal_api_token(x_internal_api_token)
|
||||||
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
|
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)
|
@router.patch("/{user_id}/preferences", response_model=UserRead)
|
||||||
async def update_preferences(
|
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:
|
||||||
user = await session.get(User, user_id)
|
if current_user.id != user_id:
|
||||||
if user is None:
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
user = current_user
|
||||||
for field, value in payload.model_dump(exclude_none=True).items():
|
for field, value in payload.model_dump(exclude_none=True).items():
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -133,10 +127,10 @@ async def save_push_subscription(
|
|||||||
payload: PushSubscriptionCreate,
|
payload: PushSubscriptionCreate,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
) -> None:
|
) -> None:
|
||||||
user = await session.get(User, user_id)
|
if current_user.id != user_id:
|
||||||
if user is None:
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(PushSubscription).where(
|
select(PushSubscription).where(
|
||||||
PushSubscription.user_id == user_id,
|
PushSubscription.user_id == user_id,
|
||||||
@@ -166,8 +160,15 @@ async def save_push_subscription(
|
|||||||
async def due_reminders(
|
async def due_reminders(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
days: int = 30,
|
days: int = 30,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
) -> list[ReminderRead]:
|
) -> 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()
|
today = date.today()
|
||||||
horizon = today + timedelta(days=max(1, min(days, 180)))
|
horizon = today + timedelta(days=max(1, min(days, 180)))
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -183,6 +184,8 @@ async def due_reminders(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(ServiceEntry.next_due_date.asc().nulls_last())
|
.order_by(ServiceEntry.next_due_date.asc().nulls_last())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await session.execute(stmt)).all()
|
||||||
reminders: list[ReminderRead] = []
|
reminders: list[ReminderRead] = []
|
||||||
|
|||||||
@@ -9,13 +9,37 @@ class Settings(BaseSettings):
|
|||||||
bot_username: str = ""
|
bot_username: str = ""
|
||||||
api_base_url: str = "http://localhost:8000"
|
api_base_url: str = "http://localhost:8000"
|
||||||
webapp_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_host: str = "0.0.0.0"
|
||||||
app_port: int = 8000
|
app_port: int = 8000
|
||||||
app_env: str = "production"
|
app_env: str = "production"
|
||||||
|
cors_origins: str = ""
|
||||||
|
internal_api_token: str = ""
|
||||||
vapid_public_key: str = ""
|
vapid_public_key: str = ""
|
||||||
|
allow_dev_auth: bool = False
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
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
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import asyncio
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ from app.models.expense import FuelEntry, ServiceEntry, ServiceType
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.catalog_data import CAR_CATALOG, CAR_TRIMS, COMMON_TRIMS, MAKE_COUNTRIES
|
from app.services.catalog_data import CAR_CATALOG, CAR_TRIMS, COMMON_TRIMS, MAKE_COUNTRIES
|
||||||
|
|
||||||
|
|
||||||
MOCK_PLATE_PREFIX = "MOCK"
|
MOCK_PLATE_PREFIX = "MOCK"
|
||||||
|
|
||||||
MOCK_CARS = [
|
MOCK_CARS = [
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api import cars, catalog, entries, ocr, service_centers, users
|
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")
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class CarBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CarCreate(CarBase):
|
class CarCreate(CarBase):
|
||||||
owner_id: int
|
owner_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class CarUpdate(BaseModel):
|
class CarUpdate(BaseModel):
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ class FuelEntryCreate(FuelEntryBase):
|
|||||||
car_id: int
|
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):
|
class FuelEntryRead(FuelEntryBase):
|
||||||
id: int
|
id: int
|
||||||
car_id: int
|
car_id: int
|
||||||
@@ -54,6 +66,19 @@ class ServiceEntryCreate(ServiceEntryBase):
|
|||||||
car_id: int
|
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):
|
class ServiceEntryRead(ServiceEntryBase):
|
||||||
id: int
|
id: int
|
||||||
car_id: int
|
car_id: int
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class TelegramLoginRequest(BaseModel):
|
|||||||
class AuthConfig(BaseModel):
|
class AuthConfig(BaseModel):
|
||||||
bot_username: str
|
bot_username: str
|
||||||
vapid_public_key: str | None = None
|
vapid_public_key: str | None = None
|
||||||
|
app_env: str
|
||||||
|
allow_dev_auth: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PushSubscriptionKeys(BaseModel):
|
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
|
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)
|
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
|
cost_per_km = float(total_cost / distance_km) if distance_km else None
|
||||||
|
|
||||||
return OwnershipStats(
|
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:
|
async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
rows = result.mappings().all()
|
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:
|
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))
|
values = dict(parse_qsl(init_data, keep_blank_values=True))
|
||||||
received_hash = values.pop("hash", "")
|
received_hash = values.pop("hash", "")
|
||||||
if not received_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:
|
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}
|
values = {key: value for key, value in payload.items() if value is not None}
|
||||||
received_hash = str(values.pop("hash", ""))
|
received_hash = str(values.pop("hash", ""))
|
||||||
if not received_hash:
|
if not received_hash:
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ class ApiClient:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.base_url = settings.api_base_url.rstrip("/")
|
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]:
|
async def upsert_user(self, telegram_user: Any) -> dict[str, Any]:
|
||||||
payload = {
|
payload = {
|
||||||
"telegram_id": telegram_user.id,
|
"telegram_id": telegram_user.id,
|
||||||
@@ -17,24 +23,30 @@ class ApiClient:
|
|||||||
"last_name": telegram_user.last_name,
|
"last_name": telegram_user.last_name,
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
|
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()
|
response.raise_for_status()
|
||||||
return response.json()
|
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:
|
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()
|
response.raise_for_status()
|
||||||
return response.json()
|
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:
|
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()
|
response.raise_for_status()
|
||||||
return response.json()
|
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:
|
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()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|||||||
11
bot/main.py
11
bot/main.py
@@ -25,7 +25,7 @@ api = ApiClient()
|
|||||||
def main_keyboard() -> ReplyKeyboardMarkup:
|
def main_keyboard() -> ReplyKeyboardMarkup:
|
||||||
return ReplyKeyboardMarkup(
|
return ReplyKeyboardMarkup(
|
||||||
keyboard=[
|
keyboard=[
|
||||||
[KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.webapp_url))],
|
[KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.effective_webapp_url))],
|
||||||
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")],
|
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")],
|
||||||
],
|
],
|
||||||
resize_keyboard=True,
|
resize_keyboard=True,
|
||||||
@@ -50,7 +50,7 @@ async def add_car(message: Message, command: CommandObject) -> None:
|
|||||||
if not name:
|
if not name:
|
||||||
await message.answer("Напиши так: /add_car Toyota Camry")
|
await message.answer("Напиши так: /add_car Toyota Camry")
|
||||||
return
|
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']}")
|
await message.answer(f"Добавил авто: {car['name']}")
|
||||||
|
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ async def add_car(message: Message, command: CommandObject) -> None:
|
|||||||
@dp.message(F.text == "Мои авто")
|
@dp.message(F.text == "Мои авто")
|
||||||
async def cars(message: Message) -> None:
|
async def cars(message: Message) -> None:
|
||||||
user = await api.upsert_user(message.from_user)
|
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:
|
if not items:
|
||||||
await message.answer("Автомобилей пока нет. Добавь через mini app или командой /add_car Название.")
|
await message.answer("Автомобилей пока нет. Добавь через mini app или командой /add_car Название.")
|
||||||
return
|
return
|
||||||
@@ -72,7 +72,7 @@ async def cars(message: Message) -> None:
|
|||||||
@dp.callback_query(F.data.startswith("stats:"))
|
@dp.callback_query(F.data.startswith("stats:"))
|
||||||
async def show_stats(callback: CallbackQuery) -> None:
|
async def show_stats(callback: CallbackQuery) -> None:
|
||||||
car_id = int(callback.data.split(":", 1)[1])
|
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"]
|
consumption = stats["avg_consumption_l_per_100km"]
|
||||||
cost_per_km = stats["cost_per_km"]
|
cost_per_km = stats["cost_per_km"]
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
@@ -106,6 +106,9 @@ async def help_message(message: Message) -> None:
|
|||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
if not settings.bot_token:
|
if not settings.bot_token:
|
||||||
raise RuntimeError("BOT_TOKEN is empty")
|
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)
|
bot = Bot(settings.bot_token)
|
||||||
await dp.start_polling(bot)
|
await dp.start_polling(bot)
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,14 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://drivers:drivers@db:5432/drivers}
|
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://drivers:drivers@db:5432/drivers}
|
||||||
BOT_TOKEN: ${BOT_TOKEN:-}
|
BOT_TOKEN: ${BOT_TOKEN:-}
|
||||||
|
BOT_USERNAME: ${BOT_USERNAME:-}
|
||||||
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
|
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
|
||||||
WEBAPP_URL: ${WEBAPP_URL:-http://localhost: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:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -40,8 +46,12 @@ services:
|
|||||||
required: false
|
required: false
|
||||||
environment:
|
environment:
|
||||||
BOT_TOKEN: ${BOT_TOKEN:-}
|
BOT_TOKEN: ${BOT_TOKEN:-}
|
||||||
|
BOT_USERNAME: ${BOT_USERNAME:-}
|
||||||
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
|
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
|
||||||
WEBAPP_URL: ${WEBAPP_URL:-http://localhost: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:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ include = ["app*", "bot*"]
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
"aiosqlite>=0.20,<1.0",
|
||||||
|
"pytest>=8.0,<9.0",
|
||||||
|
"pytest-asyncio>=0.23,<1.0",
|
||||||
"ruff>=0.4,<1.0",
|
"ruff>=0.4,<1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -35,3 +38,7 @@ target-version = "py311"
|
|||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B"]
|
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">
|
<div class="auth-panel">
|
||||||
<p class="eyebrow">Drivers</p>
|
<p class="eyebrow">Drivers</p>
|
||||||
<h1>Гараж</h1>
|
<h1>Гараж</h1>
|
||||||
<p>Войди через Telegram, чтобы привязать гараж к твоему chat_id.</p>
|
<p id="authMessage">Это приложение открывается через Telegram-бота. Откройте Mini App из Telegram.</p>
|
||||||
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
@@ -102,8 +102,8 @@
|
|||||||
<strong>ТО / ремонт</strong>
|
<strong>ТО / ремонт</strong>
|
||||||
</button>
|
</button>
|
||||||
<button class="action-card" data-action="scan">
|
<button class="action-card" data-action="scan">
|
||||||
<span>Скан чека</span>
|
<span>Разбор чека</span>
|
||||||
<strong>OCR</strong>
|
<strong>Текст</strong>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button>
|
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button>
|
||||||
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
|
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
|
||||||
<button class="menu-row" id="openNotificationsBtn">Уведомления</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">
|
<section class="drawer-section hidden" id="settingsSection">
|
||||||
<h2>Настройки</h2>
|
<h2>Настройки</h2>
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
<div class="scan-modal hidden" id="scanModal">
|
<div class="scan-modal hidden" id="scanModal">
|
||||||
<div class="scan-panel">
|
<div class="scan-panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>Скан чека</h2>
|
<h2>Разбор чека</h2>
|
||||||
<button class="icon-btn" id="closeScanBtn" aria-label="Закрыть">×</button>
|
<button class="icon-btn" id="closeScanBtn" aria-label="Закрыть">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="ocrForm" class="scan-form">
|
<form id="ocrForm" class="scan-form">
|
||||||
@@ -368,9 +368,9 @@
|
|||||||
<input id="receiptCameraInput" class="hidden-file" type="file" accept="image/*" capture="environment" />
|
<input id="receiptCameraInput" class="hidden-file" type="file" accept="image/*" capture="environment" />
|
||||||
<input id="receiptFileInput" class="hidden-file" type="file" accept="image/*,.pdf,.txt" />
|
<input id="receiptFileInput" class="hidden-file" type="file" accept="image/*,.pdf,.txt" />
|
||||||
<div id="receiptFileName" class="file-hint">Файл не выбран</div>
|
<div id="receiptFileName" class="file-hint">Файл не выбран</div>
|
||||||
<button type="submit">Распознать</button>
|
<button type="submit">Разобрать текст</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="ocrResult" class="tip-card">После распознавания поля заправки заполнятся автоматически.</div>
|
<div id="ocrResult" class="tip-card">Сейчас разбираем текстовые чеки. OCR по фото будет подключен отдельно.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -408,8 +408,13 @@ function formData(form) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
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}`, {
|
const response = await fetch(`/api${path}`, {
|
||||||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
headers,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -497,13 +502,14 @@ async function ensureUser() {
|
|||||||
hideAuthOverlay();
|
hideAuthOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stored = localStorage.getItem("driversUser");
|
if (state.authConfig?.allow_dev_auth) {
|
||||||
if (stored) {
|
const devId = localStorage.getItem("driversDevTelegramId") || "1";
|
||||||
state.user = JSON.parse(stored);
|
localStorage.setItem("driversDevTelegramId", devId);
|
||||||
|
state.user = await api("/users/me");
|
||||||
hideAuthOverlay();
|
hideAuthOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await showTelegramLogin();
|
showTelegramOpenHint();
|
||||||
throw new Error("Требуется вход через Telegram");
|
throw new Error("Требуется вход через Telegram");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,22 +518,33 @@ function hideAuthOverlay() {
|
|||||||
document.body.classList.remove("auth-required");
|
document.body.classList.remove("auth-required");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showTelegramLogin() {
|
function showTelegramOpenHint() {
|
||||||
const overlay = document.querySelector("#authOverlay");
|
const overlay = document.querySelector("#authOverlay");
|
||||||
const slot = document.querySelector("#telegramLoginSlot");
|
const slot = document.querySelector("#telegramLoginSlot");
|
||||||
const link = document.querySelector("#telegramLoginLink");
|
const link = document.querySelector("#telegramLoginLink");
|
||||||
|
const message = document.querySelector("#authMessage");
|
||||||
overlay?.classList.remove("hidden");
|
overlay?.classList.remove("hidden");
|
||||||
document.body.classList.add("auth-required");
|
document.body.classList.add("auth-required");
|
||||||
if (!slot || slot.dataset.ready) return;
|
|
||||||
const botUsername = state.authConfig?.bot_username;
|
const botUsername = state.authConfig?.bot_username;
|
||||||
|
if (message) {
|
||||||
|
message.textContent = "Это приложение открывается через Telegram-бота. Откройте Mini App из Telegram.";
|
||||||
|
}
|
||||||
|
if (slot) slot.textContent = "";
|
||||||
if (!botUsername) {
|
if (!botUsername) {
|
||||||
slot.textContent = "Telegram Login временно недоступен";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = `https://t.me/${botUsername}?start=web_login`;
|
link.href = `https://t.me/${botUsername}`;
|
||||||
link.classList.remove("hidden");
|
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) => {
|
window.onTelegramAuth = async (user) => {
|
||||||
state.user = await api("/users/telegram-login", {
|
state.user = await api("/users/telegram-login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1424,7 +1441,11 @@ document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
|
|||||||
await runAction(formButton, "Распознаю чек...", async () => {
|
await runAction(formButton, "Распознаю чек...", async () => {
|
||||||
const payload = new FormData();
|
const payload = new FormData();
|
||||||
payload.append("file", file);
|
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());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`;
|
document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`;
|
||||||
|
|||||||
Reference in New Issue
Block a user