Complete CarPass product flows

This commit is contained in:
VPN SaaS Dev
2026-05-14 21:19:37 +09:00
parent a83f55c646
commit c0014ab4ea
28 changed files with 3006 additions and 159 deletions

View File

@@ -1,11 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy import select
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, ServiceVisit, VehicleAccess
from app.models.car import (
Car,
CarServiceLink,
ServiceCenter,
ServiceVisit,
VehicleAccess,
VehicleDataChangeRequest,
)
from app.models.user import User
from app.schemas.service_center import (
VehicleAccessGrant,
@@ -15,6 +22,7 @@ from app.schemas.service_center import (
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"])
@@ -53,8 +61,13 @@ async def my_vehicles(
) -> list[Car]:
result = await session.execute(
select(Car)
.join(VehicleAccess, VehicleAccess.vehicle_id == Car.id)
.where(VehicleAccess.user_id == current_user.id, VehicleAccess.status == "active")
.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())
@@ -70,6 +83,15 @@ async def create_vehicle(
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)
@@ -88,8 +110,23 @@ async def update_vehicle(
raise HTTPException(status_code=404, detail="Vehicle not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
for field, value in vehicle_data(payload, partial=True).items():
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)
@@ -116,6 +153,85 @@ async def vehicle_service_history(
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,