274 lines
9.7 KiB
Python
274 lines
9.7 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from fastapi.encoders import jsonable_encoder
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_telegram_user, log_audit
|
|
from app.db.session import get_session
|
|
from app.models.car import (
|
|
Car,
|
|
CarServiceLink,
|
|
ServiceCenter,
|
|
ServiceVisit,
|
|
VehicleAccess,
|
|
VehicleDataChangeRequest,
|
|
)
|
|
from app.models.user import User
|
|
from app.schemas.service_center import (
|
|
VehicleAccessGrant,
|
|
VehicleAccessRead,
|
|
VehicleCreate,
|
|
VehicleRead,
|
|
VehicleUpdate,
|
|
)
|
|
from app.schemas.user import UserRead
|
|
from app.services.odometer import add_odometer_history, validate_odometer_change
|
|
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
|
|
|
router = APIRouter(tags=["my"])
|
|
|
|
|
|
@router.get("/me", response_model=UserRead)
|
|
async def me(current_user: User = Depends(get_current_telegram_user)) -> User:
|
|
return current_user
|
|
|
|
|
|
def vehicle_data(payload: VehicleCreate | VehicleUpdate, *, partial: bool = False) -> dict:
|
|
raw = payload.model_dump(exclude_unset=partial)
|
|
data = {
|
|
key: value
|
|
for key, value in raw.items()
|
|
if key not in {"license_plate", "license_plate_country", "vin"}
|
|
}
|
|
if "license_plate" in raw:
|
|
data["license_plate_display"] = raw["license_plate"]
|
|
data["license_plate_normalized"] = normalize_license_plate(raw["license_plate"])
|
|
data["plate_number"] = raw["license_plate"]
|
|
if "license_plate_country" in raw:
|
|
data["license_plate_country"] = (
|
|
raw["license_plate_country"].upper() if raw["license_plate_country"] else None
|
|
)
|
|
if "vin" in raw:
|
|
data["vin_normalized"] = validate_vin(raw["vin"])
|
|
data["vin"] = raw["vin"]
|
|
return data
|
|
|
|
|
|
@router.get("/my/vehicles", response_model=list[VehicleRead])
|
|
async def my_vehicles(
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_telegram_user),
|
|
) -> list[Car]:
|
|
result = await session.execute(
|
|
select(Car)
|
|
.outerjoin(VehicleAccess, VehicleAccess.vehicle_id == Car.id)
|
|
.where(
|
|
or_(
|
|
Car.owner_id == current_user.id,
|
|
(VehicleAccess.user_id == current_user.id) & (VehicleAccess.status == "active"),
|
|
)
|
|
)
|
|
.order_by(Car.created_at.desc())
|
|
)
|
|
return list(result.scalars())
|
|
|
|
|
|
@router.post("/my/vehicles", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
|
|
async def create_vehicle(
|
|
payload: VehicleCreate,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_telegram_user),
|
|
) -> Car:
|
|
car = Car(**vehicle_data(payload), owner_id=current_user.id)
|
|
session.add(car)
|
|
await session.flush()
|
|
session.add(VehicleAccess(vehicle_id=car.id, user_id=current_user.id, role="owner", status="active"))
|
|
if car.current_odometer is not None:
|
|
add_odometer_history(
|
|
session,
|
|
car,
|
|
new_odometer=car.current_odometer,
|
|
source_record_type="manual",
|
|
source_record_id=None,
|
|
changed_by=current_user.id,
|
|
)
|
|
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
|
|
await session.commit()
|
|
await session.refresh(car)
|
|
return car
|
|
|
|
|
|
@router.patch("/my/vehicles/{vehicle_id}", response_model=VehicleRead)
|
|
async def update_vehicle(
|
|
vehicle_id: int,
|
|
payload: VehicleUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_telegram_user),
|
|
) -> Car:
|
|
car = await session.get(Car, vehicle_id)
|
|
if car is None:
|
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
|
if car.owner_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
raw = vehicle_data(payload, partial=True)
|
|
odometer_value = raw.pop("current_odometer", None) if "current_odometer" in raw else None
|
|
if odometer_value is not None:
|
|
validate_odometer_change(car, odometer_value, source_record_type="manual", confirm_lower_odometer=True)
|
|
for field, value in raw.items():
|
|
setattr(car, field, value)
|
|
if odometer_value is not None and odometer_value != car.current_odometer:
|
|
add_odometer_history(
|
|
session,
|
|
car,
|
|
new_odometer=odometer_value,
|
|
source_record_type="manual",
|
|
source_record_id=None,
|
|
changed_by=current_user.id,
|
|
confirmation_required=car.current_odometer is not None and odometer_value < car.current_odometer,
|
|
user_confirmed=True,
|
|
)
|
|
await log_audit(session, actor=current_user, action="vehicle.update", target_type="vehicle", target_id=car.id)
|
|
await session.commit()
|
|
await session.refresh(car)
|
|
return car
|
|
|
|
|
|
@router.get("/my/vehicles/{vehicle_id}/service-history")
|
|
async def vehicle_service_history(
|
|
vehicle_id: int,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_telegram_user),
|
|
) -> dict:
|
|
car = await session.get(Car, vehicle_id)
|
|
if car is None:
|
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
|
if car.owner_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
result = await session.execute(
|
|
select(ServiceVisit)
|
|
.where(ServiceVisit.vehicle_id == vehicle_id)
|
|
.order_by(ServiceVisit.visit_date.desc())
|
|
)
|
|
visits = list(result.scalars())
|
|
return {"vehicle_id": vehicle_id, "service_visits": jsonable_encoder(visits)}
|
|
|
|
|
|
@router.get("/my/confirmations")
|
|
async def my_confirmations(
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_telegram_user),
|
|
) -> dict:
|
|
owner_cars = select(Car.id).where(Car.owner_id == current_user.id)
|
|
visits = list(
|
|
(
|
|
await session.execute(
|
|
select(ServiceVisit)
|
|
.where(
|
|
ServiceVisit.vehicle_id.in_(owner_cars),
|
|
ServiceVisit.status == "pending_owner_confirmation",
|
|
)
|
|
.order_by(ServiceVisit.updated_at.desc(), ServiceVisit.id.desc())
|
|
)
|
|
).scalars()
|
|
)
|
|
change_requests = list(
|
|
(
|
|
await session.execute(
|
|
select(VehicleDataChangeRequest)
|
|
.where(
|
|
VehicleDataChangeRequest.owner_user_id == current_user.id,
|
|
VehicleDataChangeRequest.status == "pending",
|
|
)
|
|
.order_by(VehicleDataChangeRequest.created_at.desc())
|
|
)
|
|
).scalars()
|
|
)
|
|
links = list(
|
|
(
|
|
await session.execute(
|
|
select(CarServiceLink)
|
|
.where(
|
|
CarServiceLink.car_id.in_(owner_cars),
|
|
CarServiceLink.status == "pending",
|
|
CarServiceLink.is_active.is_(False),
|
|
)
|
|
.order_by(CarServiceLink.created_at.desc())
|
|
)
|
|
).scalars()
|
|
)
|
|
return {
|
|
"service_visits": jsonable_encoder(visits),
|
|
"change_requests": jsonable_encoder(change_requests),
|
|
"service_links": jsonable_encoder(links),
|
|
}
|
|
|
|
|
|
@router.get("/my/service-links")
|
|
async def my_service_links(
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_telegram_user),
|
|
) -> list[dict]:
|
|
result = await session.execute(
|
|
select(CarServiceLink, Car, ServiceCenter)
|
|
.join(Car, Car.id == CarServiceLink.car_id)
|
|
.join(ServiceCenter, ServiceCenter.id == CarServiceLink.service_center_id)
|
|
.where(Car.owner_id == current_user.id)
|
|
.order_by(CarServiceLink.created_at.desc())
|
|
)
|
|
return [
|
|
{
|
|
"id": link.id,
|
|
"status": link.status,
|
|
"access_level": link.access_level,
|
|
"car_id": car.id,
|
|
"car_name": car.name,
|
|
"service_center_id": center.id,
|
|
"service_center_name": center.display_name or center.name,
|
|
"created_at": link.created_at,
|
|
"approved_at": link.approved_at,
|
|
"revoked_at": link.revoked_at,
|
|
}
|
|
for link, car, center in result.all()
|
|
]
|
|
|
|
|
|
@router.post("/my/vehicles/{vehicle_id}/grant-service-access", response_model=VehicleAccessRead)
|
|
async def grant_vehicle_access(
|
|
vehicle_id: int,
|
|
payload: VehicleAccessGrant,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_telegram_user),
|
|
) -> VehicleAccess:
|
|
car = await session.get(Car, vehicle_id)
|
|
if car is None:
|
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
|
if car.owner_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
if not payload.user_id:
|
|
raise HTTPException(status_code=400, detail="user_id is required for access grants")
|
|
result = await session.execute(
|
|
select(VehicleAccess).where(
|
|
VehicleAccess.vehicle_id == vehicle_id,
|
|
VehicleAccess.user_id == payload.user_id,
|
|
VehicleAccess.role == payload.role,
|
|
)
|
|
)
|
|
access = result.scalar_one_or_none()
|
|
if access is None:
|
|
access = VehicleAccess(vehicle_id=vehicle_id, user_id=payload.user_id, role=payload.role, status="active")
|
|
session.add(access)
|
|
else:
|
|
access.status = "active"
|
|
access.revoked_at = None
|
|
await log_audit(
|
|
session,
|
|
actor=current_user,
|
|
action="vehicle_access.grant",
|
|
target_type="vehicle",
|
|
target_id=vehicle_id,
|
|
metadata={"granted_user_id": payload.user_id, "role": payload.role},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(access)
|
|
return access
|