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

141
app/api/admin.py Normal file
View File

@@ -0,0 +1,141 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user, log_audit, require_platform_role
from app.db.session import get_session
from app.models.car import AuditLog, ServiceCenter, ServiceCenterVerification, ServiceVisit
from app.models.user import User
from app.schemas.service_center import ServiceCenterRead, ServiceVisitRead
router = APIRouter(prefix="/admin", tags=["admin"])
def require_admin_or_verifier(user: User) -> None:
require_platform_role(user, {"admin", "verifier", "moderator"})
@router.get("/service-centers/pending", response_model=list[ServiceCenterRead])
async def pending_service_centers(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenter]:
require_admin_or_verifier(current_user)
result = await session.execute(
select(ServiceCenter)
.where(ServiceCenter.verification_status == "pending")
.order_by(ServiceCenter.created_at.asc())
)
return list(result.scalars())
@router.post("/service-centers/{service_center_id}/verify", response_model=ServiceCenterRead)
async def verify_service_center(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_admin_or_verifier(current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "verified"
center.verified_at = datetime.now(UTC)
await mark_latest_verification(session, center.id, "verified", current_user.id)
await log_audit(session, actor=current_user, action="service_center.verify", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)
return center
@router.post("/service-centers/{service_center_id}/reject", response_model=ServiceCenterRead)
async def reject_service_center(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_admin_or_verifier(current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "rejected"
await mark_latest_verification(session, center.id, "rejected", current_user.id)
await log_audit(session, actor=current_user, action="service_center.reject", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)
return center
@router.post("/service-centers/{service_center_id}/suspend", response_model=ServiceCenterRead)
async def suspend_service_center(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_platform_role(current_user, {"admin"})
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "suspended"
center.suspended_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="service_center.suspend", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)
return center
@router.get("/audit-log")
async def audit_log(
limit: int = 100,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[dict]:
require_platform_role(current_user, {"admin", "verifier", "moderator"})
limit = min(max(limit, 1), 200)
result = await session.execute(
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit).offset(max(offset, 0))
)
return [
{
"id": item.id,
"actor_user_id": item.actor_user_id,
"actor_role": item.actor_role,
"action": item.action,
"target_type": item.target_type,
"target_id": item.target_id,
"metadata_json": item.metadata_json,
"created_at": item.created_at,
}
for item in result.scalars()
]
@router.get("/disputes", response_model=list[ServiceVisitRead])
async def disputes(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceVisit]:
require_admin_or_verifier(current_user)
result = await session.execute(
select(ServiceVisit).where(ServiceVisit.status == "disputed").order_by(ServiceVisit.updated_at.desc())
)
return list(result.scalars())
async def mark_latest_verification(
session: AsyncSession, service_center_id: int, status: str, reviewed_by: int
) -> None:
result = await session.execute(
select(ServiceCenterVerification)
.where(ServiceCenterVerification.service_center_id == service_center_id)
.order_by(ServiceCenterVerification.created_at.desc())
.limit(1)
)
verification = result.scalar_one_or_none()
if verification:
verification.status = status
verification.reviewed_by = reviewed_by
verification.reviewed_at = datetime.now(UTC)

View File

@@ -7,17 +7,27 @@ from app.db.session import get_session
from app.models.car import Car
from app.models.user import User
from app.schemas.car import CarCreate, CarRead, CarUpdate
from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(prefix="/cars", tags=["cars"])
def apply_identity_fields(data: dict) -> dict:
if "plate_number" in data:
data["license_plate_display"] = data["plate_number"]
data["license_plate_normalized"] = normalize_license_plate(data["plate_number"])
if "vin" in data:
data["vin_normalized"] = validate_vin(data["vin"])
return data
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
async def create_car(
payload: CarCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Car:
data = payload.model_dump(exclude={"owner_id"})
data = apply_identity_fields(payload.model_dump(exclude={"owner_id"}))
car = Car(**data, owner_id=current_user.id)
session.add(car)
await session.commit()
@@ -65,7 +75,7 @@ async def update_car(
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 apply_identity_fields(payload.model_dump(exclude_unset=True)).items():
setattr(car, field, value)
await session.commit()
await session.refresh(car)

View File

@@ -0,0 +1,67 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user, log_audit
from app.api.service_visits import apply_vehicle_change
from app.db.session import get_session
from app.models.car import Car, VehicleDataChangeRequest
from app.models.user import User
from app.schemas.service_center import VehicleDataChangeRequestRead
router = APIRouter(prefix="/vehicle-change-requests", tags=["vehicle-change-requests"])
@router.post("/{request_id}/approve", response_model=VehicleDataChangeRequestRead)
async def approve_vehicle_change_request(
request_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
request = await session.get(VehicleDataChangeRequest, request_id)
if request is None:
raise HTTPException(status_code=404, detail="Change request not found")
if request.owner_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
vehicle = await session.get(Car, request.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
apply_vehicle_change(vehicle, request.field_name, request.new_value)
request.status = "approved"
request.resolved_at = datetime.now(UTC)
await log_audit(
session,
actor=current_user,
action="vehicle_change_request.approve",
target_type="vehicle_change_request",
target_id=request_id,
)
await session.commit()
await session.refresh(request)
return request
@router.post("/{request_id}/reject", response_model=VehicleDataChangeRequestRead)
async def reject_vehicle_change_request(
request_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
request = await session.get(VehicleDataChangeRequest, request_id)
if request is None:
raise HTTPException(status_code=404, detail="Change request not found")
if request.owner_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
request.status = "rejected"
request.resolved_at = datetime.now(UTC)
await log_audit(
session,
actor=current_user,
action="vehicle_change_request.reject",
target_type="vehicle_change_request",
target_id=request_id,
)
await session.commit()
await session.refresh(request)
return request

View File

@@ -6,7 +6,7 @@ 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.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
from app.models.user import User
from app.services.telegram_auth import verify_webapp_init_data
@@ -20,6 +20,7 @@ async def get_or_create_telegram_user(
last_name: str | None = None,
locale: str | None = None,
currency: str | None = None,
platform_role: str | None = None,
) -> User:
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
user = result.scalar_one_or_none()
@@ -30,6 +31,7 @@ async def get_or_create_telegram_user(
"last_name": last_name,
"locale": locale,
"currency": currency,
"platform_role": platform_role,
}
if user is None:
user = User(**{key: value for key, value in payload.items() if value is not None})
@@ -92,3 +94,95 @@ async def get_owned_car(
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return car
async def user_has_vehicle_access(
session: AsyncSession, vehicle_id: int, user_id: int, roles: set[str] | None = None
) -> bool:
stmt = select(VehicleAccess).where(
VehicleAccess.vehicle_id == vehicle_id,
VehicleAccess.user_id == user_id,
VehicleAccess.status == "active",
)
if roles:
stmt = stmt.where(VehicleAccess.role.in_(roles))
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def ensure_vehicle_owner_or_access(
session: AsyncSession,
vehicle_id: int,
user: User,
roles: set[str] | None = None,
) -> 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 == user.id:
return car
if await user_has_vehicle_access(session, vehicle_id, user.id, roles):
return car
raise HTTPException(status_code=403, detail="Forbidden")
def require_platform_role(user: User, allowed: set[str]) -> None:
if user.platform_role not in allowed:
raise HTTPException(status_code=403, detail="Forbidden")
async def ensure_service_employee(
session: AsyncSession,
service_center_id: int,
user: User,
allowed_roles: set[str] | None = None,
) -> ServiceEmployee:
result = await session.execute(
select(ServiceEmployee).where(
ServiceEmployee.service_center_id == service_center_id,
ServiceEmployee.user_id == user.id,
ServiceEmployee.status == "active",
)
)
employee = result.scalar_one_or_none()
center = await session.get(ServiceCenter, service_center_id)
owner_allowed = center is not None and center.owner_user_id == user.id
if employee is None and owner_allowed:
employee = ServiceEmployee(
service_center_id=service_center_id,
user_id=user.id,
role="owner",
status="active",
)
session.add(employee)
await session.flush()
if employee is None:
raise HTTPException(status_code=403, detail="Service center access required")
if allowed_roles and employee.role not in allowed_roles:
raise HTTPException(status_code=403, detail="Insufficient service role")
return employee
async def log_audit(
session: AsyncSession,
*,
actor: User | None,
action: str,
target_type: str,
target_id: int | str | None = None,
metadata: dict | None = None,
ip: str | None = None,
user_agent: str | None = None,
) -> None:
session.add(
AuditLog(
actor_user_id=actor.id if actor else None,
actor_role=actor.platform_role if actor else None,
action=action,
target_type=target_type,
target_id=str(target_id) if target_id is not None else None,
metadata_json=metadata,
ip=ip,
user_agent=user_agent[:256] if user_agent else None,
)
)

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

View File

@@ -6,6 +6,7 @@ from pydantic import BaseModel
from app.api.deps import get_current_telegram_user
from app.models.user import User
from app.services.ocr_provider import get_ocr_provider
router = APIRouter(prefix="/ocr", tags=["ocr"])
@@ -19,6 +20,17 @@ class ReceiptSuggestion(BaseModel):
message: str
class OCRCandidateRead(BaseModel):
type: str
value: str
confidence: float
class OCRResultRead(BaseModel):
recognized_text: str
candidates: list[OCRCandidateRead]
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
async def parse_text_receipt(
file: UploadFile = File(...),
@@ -81,6 +93,42 @@ async def scan_fuel_receipt(
return await parse_text_receipt(file, current_user)
@router.post("/license-plate", response_model=OCRResultRead)
async def recognize_license_plate(
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
) -> OCRResultRead:
result = await get_ocr_provider().recognize(await file.read(), file.filename)
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
)
@router.post("/vin", response_model=OCRResultRead)
async def recognize_vin(
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
) -> OCRResultRead:
result = await get_ocr_provider().recognize(await file.read(), file.filename)
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
)
@router.post("/service-document", response_model=OCRResultRead)
async def recognize_service_document(
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
) -> OCRResultRead:
result = await get_ocr_provider().recognize(await file.read(), file.filename)
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
)
def detect_station(text: str) -> str | None:
stations = {
"shell": "Shell",

View File

@@ -2,35 +2,93 @@ from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_internal_api_token
from app.api.deps import (
ensure_service_employee,
get_current_telegram_user,
get_or_create_telegram_user,
log_audit,
require_internal_api_token,
)
from app.db.session import get_session
from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage
from app.models.car import (
Car,
CarServiceLink,
ServiceCenter,
ServiceCenterVerification,
ServiceEmployee,
ServiceInboxMessage,
ServiceVisit,
)
from app.models.user import User
from app.schemas.service_center import (
CarServiceLinkCreate,
CarServiceLinkRead,
ServiceCenterCreate,
ServiceCenterRead,
ServiceCenterVerificationCreate,
ServiceCenterVerificationRead,
ServiceEmployeeInvite,
ServiceEmployeeRead,
ServiceInboxCreate,
ServiceInboxRead,
ServiceVisitCreate,
ServiceVisitRead,
VehicleSearchRequest,
VehicleSearchResult,
)
from app.services.vehicle_identity import mask_license_plate, mask_vin
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
async def create_service_center(
payload: ServiceCenterCreate,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_internal_api_token(x_internal_api_token)
center = ServiceCenter(**payload.model_dump())
center = ServiceCenter(
name=payload.display_name,
display_name=payload.display_name,
legal_name=payload.legal_name,
country=payload.country.upper() if payload.country else None,
city=payload.city,
address=payload.address,
phone=payload.phone,
contact_phone=payload.contact_phone or payload.phone,
telegram_chat_id=payload.telegram_chat_id,
business_registration_number=payload.business_registration_number,
owner_user_id=current_user.id,
verification_status="pending",
)
session.add(center)
await session.flush()
employee = ServiceEmployee(
service_center_id=center.id,
user_id=current_user.id,
role="owner",
status="active",
)
session.add(employee)
await log_audit(session, actor=current_user, action="service_center.create", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)
return center
@router.get("/my", response_model=list[ServiceCenterRead])
async def my_service_centers(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenter]:
result = await session.execute(
select(ServiceCenter)
.join(ServiceEmployee, ServiceEmployee.service_center_id == ServiceCenter.id)
.where(ServiceEmployee.user_id == current_user.id, ServiceEmployee.status == "active")
.order_by(ServiceCenter.created_at.desc())
)
return list(result.scalars())
@router.get("", response_model=list[ServiceCenterRead])
async def list_service_centers(
session: AsyncSession = Depends(get_session),
@@ -41,6 +99,152 @@ async def list_service_centers(
return list(result.scalars())
@router.post("/{service_center_id}/verification", response_model=ServiceCenterVerificationRead)
async def submit_verification(
service_center_id: int,
payload: ServiceCenterVerificationCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterVerification:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
verification = ServiceCenterVerification(
service_center_id=service_center_id,
submitted_documents=payload.submitted_documents,
comment=payload.comment,
status="pending",
)
session.add(verification)
center = await session.get(ServiceCenter, service_center_id)
if center:
center.verification_status = "pending"
await log_audit(session, actor=current_user, action="service_center.verification.submit", target_type="service_center", target_id=service_center_id)
await session.commit()
await session.refresh(verification)
return verification
@router.post("/{service_center_id}/employees/invite", response_model=ServiceEmployeeRead)
async def invite_employee(
service_center_id: int,
payload: ServiceEmployeeInvite,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEmployee:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
user = await get_or_create_telegram_user(session, telegram_id=payload.telegram_id)
result = await session.execute(
select(ServiceEmployee).where(
ServiceEmployee.service_center_id == service_center_id,
ServiceEmployee.user_id == user.id,
)
)
employee = result.scalar_one_or_none()
if employee is None:
employee = ServiceEmployee(
service_center_id=service_center_id,
user_id=user.id,
role=payload.role,
permissions=payload.permissions,
status="invited",
)
session.add(employee)
else:
employee.role = payload.role
employee.permissions = payload.permissions
employee.status = "invited"
await log_audit(session, actor=current_user, action="service_employee.invite", target_type="service_center", target_id=service_center_id, metadata={"telegram_id": payload.telegram_id})
await session.commit()
await session.refresh(employee)
return employee
@router.get("/{service_center_id}/visits", response_model=list[ServiceVisitRead])
async def service_center_visits(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceVisit]:
await ensure_service_employee(session, service_center_id, current_user)
result = await session.execute(
select(ServiceVisit)
.where(ServiceVisit.service_center_id == service_center_id)
.order_by(ServiceVisit.visit_date.desc(), ServiceVisit.id.desc())
)
return list(result.scalars())
@router.post("/{service_center_id}/visits", response_model=ServiceVisitRead, status_code=status.HTTP_201_CREATED)
async def create_visit(
service_center_id: int,
payload: ServiceVisitCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
employee = await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
vehicle = await session.get(Car, payload.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
center = await session.get(ServiceCenter, service_center_id)
if center and center.verification_status not in {"verified", "pending"}:
raise HTTPException(status_code=403, detail="Service center is not allowed to create visits")
visit = ServiceVisit(
service_center_id=service_center_id,
vehicle_id=payload.vehicle_id,
created_by_employee_id=employee.id,
visit_date=payload.visit_date,
odometer=payload.odometer,
notes=payload.notes,
total_cost=payload.total_cost,
currency=payload.currency,
status="draft",
)
session.add(visit)
await log_audit(session, actor=current_user, action="service_visit.create", target_type="service_visit", metadata={"vehicle_id": payload.vehicle_id})
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{service_center_id}/vehicle-access/request")
async def request_vehicle_access(
service_center_id: int,
payload: VehicleSearchRequest,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleSearchResult:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
stmt = select(Car)
if payload.vin:
stmt = stmt.where(Car.vin_normalized == payload.vin)
elif payload.license_plate:
stmt = stmt.where(Car.license_plate_normalized == payload.license_plate)
if payload.country_code:
stmt = stmt.where(Car.license_plate_country == payload.country_code.upper())
else:
raise HTTPException(status_code=400, detail="license_plate or vin is required")
vehicle = (await session.execute(stmt.limit(1))).scalar_one_or_none()
await log_audit(
session,
actor=current_user,
action="vehicle_access.request",
target_type="vehicle",
target_id=vehicle.id if vehicle else None,
metadata={"service_center_id": service_center_id, "found": bool(vehicle)},
)
await session.commit()
if vehicle is None:
return VehicleSearchResult(access_status="not_found")
return VehicleSearchResult(
vehicle_id=vehicle.id,
make=vehicle.make,
model=vehicle.model,
year=vehicle.year,
masked_license_plate=mask_license_plate(vehicle.license_plate_display or vehicle.plate_number),
masked_vin=mask_vin(vehicle.vin_normalized or vehicle.vin),
access_status="request_logged",
)
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
async def link_car_to_service(
payload: CarServiceLinkCreate,

205
app/api/service_visits.py Normal file
View File

@@ -0,0 +1,205 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit
from app.db.session import get_session
from app.models.car import Car, ServiceVisit, ServiceWorkItem, VehicleDataChangeRequest
from app.models.user import User
from app.schemas.service_center import (
ServiceVisitRead,
ServiceWorkItemCreate,
ServiceWorkItemRead,
VehicleDataChangeRequestCreate,
VehicleDataChangeRequestRead,
)
from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(prefix="/service-visits", tags=["service-visits"])
async def get_visit_or_404(session: AsyncSession, visit_id: int) -> ServiceVisit:
visit = await session.get(ServiceVisit, visit_id)
if visit is None:
raise HTTPException(status_code=404, detail="Service visit not found")
return visit
@router.post("/{visit_id}/work-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED)
async def add_work_item(
visit_id: int,
payload: ServiceWorkItemCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceWorkItem:
visit = await get_visit_or_404(session, visit_id)
await ensure_service_employee(
session,
visit.service_center_id,
current_user,
{"owner", "manager", "mechanic"},
)
if visit.status not in {"draft", "pending_owner_confirmation"}:
raise HTTPException(status_code=409, detail="Visit cannot be edited in current status")
item = ServiceWorkItem(service_visit_id=visit_id, **payload.model_dump())
session.add(item)
if payload.price is not None:
visit.total_cost = (visit.total_cost or 0) + payload.price
await log_audit(session, actor=current_user, action="service_work_item.create", target_type="service_visit", target_id=visit_id)
await session.commit()
await session.refresh(item)
return item
@router.post("/{visit_id}/complete", response_model=ServiceVisitRead)
async def complete_visit(
visit_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_visit_or_404(session, visit_id)
await ensure_service_employee(session, visit.service_center_id, current_user, {"owner", "manager"})
if visit.status not in {"draft", "pending_owner_confirmation"}:
raise HTTPException(status_code=409, detail="Visit cannot be completed")
visit.status = "pending_owner_confirmation"
await log_audit(session, actor=current_user, action="service_visit.complete", target_type="service_visit", target_id=visit_id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{visit_id}/confirm", response_model=ServiceVisitRead)
async def confirm_visit(
visit_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_visit_or_404(session, visit_id)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
visit.status = "confirmed"
visit.owner_resolved_at = datetime.now(UTC)
if visit.odometer and (vehicle.current_odometer is None or visit.odometer > vehicle.current_odometer):
vehicle.current_odometer = visit.odometer
await log_audit(session, actor=current_user, action="service_visit.confirm", target_type="service_visit", target_id=visit_id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{visit_id}/dispute", response_model=ServiceVisitRead)
async def dispute_visit(
visit_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_visit_or_404(session, visit_id)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
visit.status = "disputed"
visit.owner_resolved_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="service_visit.dispute", target_type="service_visit", target_id=visit_id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{visit_id}/vehicle-change-requests", response_model=VehicleDataChangeRequestRead)
async def create_vehicle_change_request(
visit_id: int,
payload: VehicleDataChangeRequestCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
visit = await get_visit_or_404(session, visit_id)
employee = await ensure_service_employee(
session,
visit.service_center_id,
current_user,
{"owner", "manager", "mechanic", "receptionist"},
)
if visit.vehicle_id != payload.vehicle_id:
raise HTTPException(status_code=400, detail="Vehicle does not match visit")
vehicle = await session.get(Car, payload.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
old_value = getattr(vehicle, payload.field_name, None)
request = VehicleDataChangeRequest(
vehicle_id=payload.vehicle_id,
requested_by_service_center_id=visit.service_center_id,
requested_by_employee_id=employee.id,
field_name=payload.field_name,
old_value=str(old_value) if old_value is not None else None,
new_value=payload.new_value,
status="pending",
owner_user_id=vehicle.owner_id,
)
session.add(request)
await log_audit(session, actor=current_user, action="vehicle_change_request.create", target_type="vehicle", target_id=payload.vehicle_id, metadata={"field_name": payload.field_name})
await session.commit()
await session.refresh(request)
return request
@router.post("/vehicle-change-requests/{request_id}/approve", response_model=VehicleDataChangeRequestRead)
async def approve_vehicle_change_request(
request_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
request = await session.get(VehicleDataChangeRequest, request_id)
if request is None:
raise HTTPException(status_code=404, detail="Change request not found")
if request.owner_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
vehicle = await session.get(Car, request.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
apply_vehicle_change(vehicle, request.field_name, request.new_value)
request.status = "approved"
request.resolved_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="vehicle_change_request.approve", target_type="vehicle_change_request", target_id=request_id)
await session.commit()
await session.refresh(request)
return request
@router.post("/vehicle-change-requests/{request_id}/reject", response_model=VehicleDataChangeRequestRead)
async def reject_vehicle_change_request(
request_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
request = await session.get(VehicleDataChangeRequest, request_id)
if request is None:
raise HTTPException(status_code=404, detail="Change request not found")
if request.owner_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
request.status = "rejected"
request.resolved_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="vehicle_change_request.reject", target_type="vehicle_change_request", target_id=request_id)
await session.commit()
await session.refresh(request)
return request
def apply_vehicle_change(vehicle: Car, field_name: str, value: str | None) -> None:
if field_name in {"license_plate", "license_plate_display"}:
vehicle.license_plate_display = value
vehicle.license_plate_normalized = normalize_license_plate(value)
vehicle.plate_number = value
return
if field_name in {"vin", "vin_normalized"}:
vehicle.vin = value
vehicle.vin_normalized = validate_vin(value)
return
if not hasattr(vehicle, field_name):
raise HTTPException(status_code=400, detail="Unsupported vehicle field")
setattr(vehicle, field_name, value)

View File

@@ -47,7 +47,7 @@ async def upsert_user(
@router.get("/auth/config", response_model=AuthConfig)
async def auth_config() -> AuthConfig:
return AuthConfig(
bot_username=settings.bot_username or "seoulmate_officialbot",
bot_username=settings.bot_username 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,