Mechanic's work place
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:04:56 +09:00
parent fec9635079
commit 83ad880b9d
39 changed files with 2951 additions and 74 deletions

View File

@@ -1,6 +1,7 @@
from datetime import UTC, datetime
import secrets
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -48,6 +49,7 @@ from app.schemas.service_center import (
)
from app.services.notifications import notify_platform_moderators
from app.services.odometer import validate_odometer_change
from app.services.rate_limit import check_rate_limit
from app.services.vehicle_identity import mask_license_plate, mask_vin
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
@@ -162,12 +164,17 @@ async def my_service_centers(
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenter]:
result = await session.execute(
select(ServiceCenter)
select(ServiceCenter, ServiceEmployee.role, ServiceEmployee.status)
.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())
centers = []
for center, role, employee_status in result.all():
center.employee_role = role
center.employee_status = employee_status
centers.append(center)
return centers
@router.get("/public", response_model=list[ServiceCenterPublicRead])
@@ -253,9 +260,11 @@ async def submit_verification(
async def invite_employee(
service_center_id: int,
payload: ServiceEmployeeInvite,
request: Request,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEmployee:
await check_rate_limit(scope="employee_invite", limit=10, window_seconds=3600, request=request, user=current_user, session=session)
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(
@@ -272,18 +281,95 @@ async def invite_employee(
role=payload.role,
permissions=payload.permissions,
status="invited",
invite_token=secrets.token_urlsafe(32),
invite_expires_at=datetime.now(UTC) + timedelta(hours=payload.expires_in_hours),
)
session.add(employee)
else:
employee.role = payload.role
employee.permissions = payload.permissions
employee.status = "invited"
employee.invite_token = secrets.token_urlsafe(32)
employee.invite_expires_at = datetime.now(UTC) + timedelta(hours=payload.expires_in_hours)
employee.invite_revoked_at = None
employee.activated_at = None
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.post("/employees/invites/{invite_token}/accept", response_model=ServiceEmployeeRead)
async def accept_employee_invite(
invite_token: str,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEmployee:
result = await session.execute(
select(ServiceEmployee).where(ServiceEmployee.invite_token == invite_token)
)
employee = result.scalar_one_or_none()
if employee is None:
raise HTTPException(status_code=404, detail="Invite not found")
if employee.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Invite belongs to another Telegram account")
if employee.status != "invited":
raise HTTPException(status_code=409, detail="Invite is not active")
if employee.invite_revoked_at is not None:
raise HTTPException(status_code=409, detail="Invite was revoked")
if employee.invite_expires_at:
expires_at = employee.invite_expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
else:
expires_at = None
if expires_at and expires_at <= datetime.now(UTC):
employee.status = "expired"
await log_audit(session, actor=current_user, action="service_employee.invite_expired", target_type="service_employee", target_id=employee.id)
await session.commit()
raise HTTPException(status_code=409, detail="Invite expired")
employee.status = "active"
employee.activated_at = datetime.now(UTC)
employee.invite_token = None
await log_audit(
session,
actor=current_user,
action="service_employee.invite_accept",
target_type="service_employee",
target_id=employee.id,
)
await session.commit()
await session.refresh(employee)
return employee
@router.post("/employees/{employee_id}/revoke-invite", response_model=ServiceEmployeeRead)
async def revoke_employee_invite(
employee_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEmployee:
employee = await session.get(ServiceEmployee, employee_id)
if employee is None:
raise HTTPException(status_code=404, detail="Employee not found")
await ensure_service_employee(session, employee.service_center_id, current_user, {"owner", "manager"})
if employee.status != "invited":
raise HTTPException(status_code=409, detail="Only invited employees can be revoked")
employee.status = "revoked"
employee.invite_revoked_at = datetime.now(UTC)
employee.invite_token = None
await log_audit(
session,
actor=current_user,
action="service_employee.invite_revoke",
target_type="service_employee",
target_id=employee.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,
@@ -355,6 +441,7 @@ async def create_visit(
visit = ServiceVisit(
service_center_id=service_center_id,
vehicle_id=payload.vehicle_id,
owner_id=vehicle.owner_id,
created_by_employee_id=employee.id,
visit_date=payload.visit_date,
odometer=payload.odometer,
@@ -374,9 +461,11 @@ async def create_visit(
async def request_vehicle_access(
service_center_id: int,
payload: VehicleSearchRequest,
request: Request,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleSearchResult:
await check_rate_limit(scope="vehicle_access_request", limit=20, window_seconds=3600, request=request, user=current_user, session=session)
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
await ensure_service_center_approved(session, service_center_id)
stmt = select(Car)
@@ -610,9 +699,11 @@ async def service_center_reviews(
async def create_service_center_review(
service_center_id: int,
payload: ServiceCenterReviewCreate,
request: Request,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReview:
await check_rate_limit(scope="service_review", limit=10, window_seconds=3600, request=request, user=current_user, session=session)
await ensure_service_center_approved(session, service_center_id)
result = await session.execute(
select(ServiceCenterReview).where(