Add service platform foundation

This commit is contained in:
VPN SaaS Dev
2026-05-12 19:45:08 +09:00
parent 2ba2e88432
commit 34035a27cb
23 changed files with 2199 additions and 18 deletions

157
app/api/my.py Normal file
View File

@@ -0,0 +1,157 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy import 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.user import User
from app.schemas.service_center import (
VehicleAccessGrant,
VehicleAccessRead,
VehicleCreate,
VehicleRead,
VehicleUpdate,
)
from app.schemas.user import UserRead
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)
.join(VehicleAccess, VehicleAccess.vehicle_id == Car.id)
.where(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"))
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")
for field, value in vehicle_data(payload, partial=True).items():
setattr(car, field, value)
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.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