first commit
This commit is contained in:
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
57
app/api/cars.py
Normal file
57
app/api/cars.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.car import Car
|
||||
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())
|
||||
session.add(car)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return car
|
||||
|
||||
|
||||
@router.get("", response_model=list[CarRead])
|
||||
async def list_cars(owner_id: int, session: AsyncSession = Depends(get_session)) -> list[Car]:
|
||||
result = await session.execute(
|
||||
select(Car).where(Car.owner_id == owner_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:
|
||||
car = await session.get(Car, car_id)
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
return car
|
||||
|
||||
|
||||
@router.patch("/{car_id}", response_model=CarRead)
|
||||
async def update_car(
|
||||
car_id: int, payload: CarUpdate, session: 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")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(car, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return 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:
|
||||
car = await session.get(Car, car_id)
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
await session.delete(car)
|
||||
await session.commit()
|
||||
21
app/api/catalog.py
Normal file
21
app/api/catalog.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.car import CarMake
|
||||
from app.schemas.car import CarMakeRead
|
||||
|
||||
router = APIRouter(prefix="/catalog", tags=["catalog"])
|
||||
|
||||
|
||||
@router.get("/makes", response_model=list[CarMakeRead])
|
||||
async def list_makes(session: AsyncSession = Depends(get_session)) -> list[CarMake]:
|
||||
result = await session.execute(
|
||||
select(CarMake).options(selectinload(CarMake.models)).order_by(CarMake.name)
|
||||
)
|
||||
makes = list(result.scalars())
|
||||
for make in makes:
|
||||
make.models.sort(key=lambda model: model.name)
|
||||
return makes
|
||||
160
app/api/entries.py
Normal file
160
app/api/entries.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from io import BytesIO
|
||||
from datetime import date
|
||||
|
||||
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.db.session import get_session
|
||||
from app.models.car import Car
|
||||
from app.models.expense import FuelEntry, ServiceEntry
|
||||
from app.schemas.expense import (
|
||||
FuelEntryCreate,
|
||||
FuelEntryRead,
|
||||
OdometerPrediction,
|
||||
OwnershipStats,
|
||||
ServiceEntryCreate,
|
||||
ServiceEntryRead,
|
||||
)
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
|
||||
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fuel_entry(
|
||||
payload: FuelEntryCreate, session: AsyncSession = Depends(get_session)
|
||||
) -> FuelEntry:
|
||||
await ensure_car(session, payload.car_id)
|
||||
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):
|
||||
car.current_odometer = payload.odometer
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/cars/{car_id}/fuel", response_model=list[FuelEntryRead])
|
||||
async def list_fuel_entries(
|
||||
car_id: int,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[FuelEntry]:
|
||||
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())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@router.post("/service", response_model=ServiceEntryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_service_entry(
|
||||
payload: ServiceEntryCreate, session: AsyncSession = Depends(get_session)
|
||||
) -> ServiceEntry:
|
||||
await ensure_car(session, payload.car_id)
|
||||
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
|
||||
):
|
||||
car.current_odometer = payload.odometer
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/cars/{car_id}/service", response_model=list[ServiceEntryRead])
|
||||
async def list_service_entries(
|
||||
car_id: int,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ServiceEntry]:
|
||||
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())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@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),
|
||||
) -> OwnershipStats:
|
||||
await ensure_car(session, car_id)
|
||||
today = date.today()
|
||||
period_from = date_from or today.replace(day=1)
|
||||
period_to = date_to or today
|
||||
return await get_ownership_stats(session, car_id, period_from, period_to)
|
||||
|
||||
|
||||
@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)
|
||||
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)
|
||||
fuel_df = await dataframe_from_query(
|
||||
session,
|
||||
select(FuelEntry.entry_date.label("date"), FuelEntry.total_cost.label("cost")).where(
|
||||
FuelEntry.car_id == car_id
|
||||
),
|
||||
)
|
||||
service_df = await dataframe_from_query(
|
||||
session,
|
||||
select(ServiceEntry.entry_date.label("date"), ServiceEntry.total_cost.label("cost")).where(
|
||||
ServiceEntry.car_id == car_id
|
||||
),
|
||||
)
|
||||
if fuel_df.empty and service_df.empty:
|
||||
raise HTTPException(status_code=404, detail="No data for chart")
|
||||
|
||||
frames = []
|
||||
if not fuel_df.empty:
|
||||
fuel_df["type"] = "fuel"
|
||||
frames.append(fuel_df)
|
||||
if not service_df.empty:
|
||||
service_df["type"] = "service"
|
||||
frames.append(service_df)
|
||||
|
||||
import pandas as pd
|
||||
|
||||
df = pd.concat(frames)
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
pivot = df.pivot_table(index="date", columns="type", values="cost", aggfunc="sum").sort_index()
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5))
|
||||
pivot.plot(kind="bar", stacked=True, ax=ax)
|
||||
ax.set_title("Car expenses")
|
||||
ax.set_xlabel("Date")
|
||||
ax.set_ylabel("Cost")
|
||||
fig.tight_layout()
|
||||
|
||||
buffer = BytesIO()
|
||||
fig.savefig(buffer, format="png")
|
||||
plt.close(fig)
|
||||
return Response(buffer.getvalue(), media_type="image/png")
|
||||
41
app/api/ocr.py
Normal file
41
app/api/ocr.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/ocr", tags=["ocr"])
|
||||
|
||||
|
||||
class ReceiptSuggestion(BaseModel):
|
||||
total_cost: Decimal | None = None
|
||||
liters: Decimal | None = None
|
||||
price_per_liter: Decimal | None = None
|
||||
station: str | None = None
|
||||
confidence: float
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/fuel-receipt", response_model=ReceiptSuggestion)
|
||||
async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
|
||||
content = await file.read()
|
||||
text = content.decode("utf-8", errors="ignore")
|
||||
numbers = [Decimal(item.replace(",", ".")) for item in re.findall(r"\d+[,.]\d+|\d+", text)]
|
||||
total = max(numbers) if numbers else None
|
||||
liters = next((item for item in numbers if Decimal("5") <= item <= Decimal("120")), None)
|
||||
price = None
|
||||
if total and liters and liters:
|
||||
price = (total / liters).quantize(Decimal("0.01"))
|
||||
|
||||
return ReceiptSuggestion(
|
||||
total_cost=total,
|
||||
liters=liters,
|
||||
price_per_liter=price,
|
||||
station=None,
|
||||
confidence=0.35 if numbers else 0,
|
||||
message=(
|
||||
"OCR-модуль готов к подключению движка распознавания. Сейчас извлекаю числа из текстового слоя/имени файла."
|
||||
if numbers
|
||||
else "Не удалось распознать чек. Можно заполнить поля вручную, а OCR-движок подключить отдельным сервисом."
|
||||
),
|
||||
)
|
||||
46
app/api/users.py
Normal file
46
app/api/users.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserPreferencesUpdate, UserRead, UserUpsert
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.post("", response_model=UserRead)
|
||||
async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User:
|
||||
result = await session.execute(select(User).where(User.telegram_id == payload.telegram_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(**payload.model_dump(exclude_none=True))
|
||||
session.add(user)
|
||||
else:
|
||||
for field, value in payload.model_dump(exclude_none=True).items():
|
||||
setattr(user, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/telegram/{telegram_id}", response_model=UserRead)
|
||||
async def get_user_by_telegram_id(
|
||||
telegram_id: int, session: AsyncSession = Depends(get_session)
|
||||
) -> User:
|
||||
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@router.patch("/{user_id}/preferences", response_model=UserRead)
|
||||
async def update_preferences(
|
||||
user_id: int, payload: UserPreferencesUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
for field, value in payload.model_dump(exclude_none=True).items():
|
||||
setattr(user, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
Reference in New Issue
Block a user