106 KiB
Premium Mechanic PWA — Phase 1 MVP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Working PWA for parking mechanics to perform vehicle inspections with photos, simple point-markers, and "before/after" comparison on return-inspections — independent of vendor's app.
Architecture: React+Vite PWA on mechanic.pptaxi.ru talks to FastAPI module mechanic (new sub-app inside existing TaxiDashboard backend on 100.64.0.12). PostgreSQL stores inspection meta, MinIO bucket pp-inspections stores photos (uploaded via pre-signed POST, served via pre-signed GET). No annotation/polygon editor in Phase 1 — only point markers on photos and zone-clicks on a SVG vehicle scheme.
Tech Stack:
- Backend: Python 3.11+, FastAPI, SQLAlchemy 2.x, Alembic, asyncpg, aiobotocore, Pillow, Celery
- Frontend: React 18, TypeScript, Vite, Tailwind, shadcn/ui, TanStack Query, Zustand, React Router 7, html5-qrcode, vite-plugin-pwa
- Storage: MinIO (s3.pptaxi.ru, bucket
pp-inspections) - Deploy: Caddy on 100.64.0.12, sub-domain
mechanic.pptaxi.ru
Spec: PremiumDriverApp/docs/superpowers/specs/2026-05-16-premium-mechanic-design.md
File Structure
Backend (extends existing TaxiDashboard FastAPI app)
Exact paths depend on existing layout. Task A1 below reconciles. Assumed paths reference
backend/app/as the package root.
- Create:
backend/app/mechanic/__init__.py - Create:
backend/app/mechanic/router.py(FastAPI APIRouter,/api/v1/mechanic/*) - Create:
backend/app/mechanic/models.py(Inspection, InspectionPhoto, DamageMarker) - Create:
backend/app/mechanic/schemas.py(Pydantic) - Create:
backend/app/mechanic/service.py(business logic: start_inspection, copy_markers, etc.) - Create:
backend/app/mechanic/storage.py(aiobotocore wrapper for MinIO) - Create:
backend/app/mechanic/deps.py(auth + permission deps) - Create:
backend/app/mechanic/tasks.py(Celery: thumbnails) - Create:
backend/alembic/versions/2026_05_16_1200_mechanic_init.py(migration) - Modify:
backend/app/main.py— registermechanic_router - Create:
backend/tests/test_mechanic/test_inspections_api.py - Create:
backend/tests/test_mechanic/test_storage.py
Frontend (new project)
- Create:
mechanic-pwa/frontend/package.json - Create:
mechanic-pwa/frontend/vite.config.ts - Create:
mechanic-pwa/frontend/tsconfig.json - Create:
mechanic-pwa/frontend/tailwind.config.ts - Create:
mechanic-pwa/frontend/index.html - Create:
mechanic-pwa/frontend/public/manifest.webmanifest - Create:
mechanic-pwa/frontend/src/main.tsx - Create:
mechanic-pwa/frontend/src/App.tsx - Create:
mechanic-pwa/frontend/src/api/client.ts - Create:
mechanic-pwa/frontend/src/api/auth.ts - Create:
mechanic-pwa/frontend/src/api/vehicles.ts - Create:
mechanic-pwa/frontend/src/api/inspections.ts - Create:
mechanic-pwa/frontend/src/api/photos.ts - Create:
mechanic-pwa/frontend/src/store/auth.ts - Create:
mechanic-pwa/frontend/src/store/inspectionDraft.ts - Create:
mechanic-pwa/frontend/src/pages/Login.tsx - Create:
mechanic-pwa/frontend/src/pages/Home.tsx - Create:
mechanic-pwa/frontend/src/pages/VehicleCard.tsx - Create:
mechanic-pwa/frontend/src/pages/InspectionEditor.tsx - Create:
mechanic-pwa/frontend/src/pages/InspectionReview.tsx - Create:
mechanic-pwa/frontend/src/components/camera/CameraCapture.tsx - Create:
mechanic-pwa/frontend/src/components/camera/PhotoSlot.tsx - Create:
mechanic-pwa/frontend/src/components/marker/PointMarkerOverlay.tsx - Create:
mechanic-pwa/frontend/src/components/marker/MarkerModal.tsx - Create:
mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx - Create:
mechanic-pwa/frontend/src/components/vehicle-scheme/HotSpot.tsx - Create:
mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg - Create:
mechanic-pwa/frontend/src/lib/coords.ts
Ops
- Create:
mechanic-pwa/ops/Caddyfile.fragment(Caddy config formechanic.pptaxi.ru) - Create:
mechanic-pwa/ops/minio-cors.json(CORS policy for bucket)
Conventions
- TDD: backend tests use pytest+httpx async client; write failing test FIRST, then implement, then verify pass
- Commits: small, atomic, after each task
- Branching: feature branch
feat/mechanic-mvpoffmain(or current dev branch) - No placeholders in code: every step is fully executable
Phase A — Infrastructure & Database (1 day)
Task A1: Inspect existing TaxiDashboard backend structure
Files: read-only
- Step 1: SSH onto VDS, identify backend layout
Run on host machine:
ssh root@100.64.0.12
cd /opt/sites/taxi-dashboard
ls -la
find . -maxdepth 4 -type d -name "app" -o -name "alembic" 2>/dev/null
cat backend/app/main.py 2>/dev/null | head -40 # or wherever main.py lives
Expected: identify exact package layout, e.g.:
-
/opt/sites/taxi-dashboard/backend/app/is FastAPI root -
/opt/sites/taxi-dashboard/backend/alembic/is migrations dir -
Existing modules under
backend/app/<modulename>/ -
Step 2: Document findings
Write down (in mechanic-pwa/README.md or in a comment block at top of router.py later):
Backend root: /opt/sites/taxi-dashboard/backend/app
Alembic dir: /opt/sites/taxi-dashboard/backend/alembic
Auth: <existing dep, name>
DB session: <existing dep, name>
User model path: <existing>
Vehicle model path: <existing>
MinIO client init: <existing>
These will be referenced by remaining backend tasks. If paths differ from this plan's assumptions, replace backend/app/... consistently throughout subsequent tasks.
- Step 3: Commit notes
git add mechanic-pwa/README.md
git commit -m "docs(mechanic): record existing backend layout"
Task A2: Caddyfile for mechanic.pptaxi.ru
Files:
-
Create:
mechanic-pwa/ops/Caddyfile.fragment -
Step 1: Write Caddy fragment
Create mechanic-pwa/ops/Caddyfile.fragment:
mechanic.pptaxi.ru {
encode gzip
# API → existing TaxiDashboard backend
handle /api/* {
reverse_proxy 127.0.0.1:8000
}
# PWA static
handle {
root * /opt/sites/mechanic-pwa/dist
try_files {path} /index.html
file_server
}
log {
output file /var/log/caddy/mechanic.pptaxi.ru.log
}
}
(Replace 127.0.0.1:8000 if the TaxiDashboard backend listens on a different port — check Task A1 notes.)
- Step 2: Test config syntax locally (or on VDS)
Run on VDS:
caddy validate --config /etc/caddy/Caddyfile
Expected: Valid configuration (after wiring fragment into main Caddyfile or copying into appropriate include).
- Step 3: Commit (don't reload yet — wait for deploy phase)
git add mechanic-pwa/ops/Caddyfile.fragment
git commit -m "ops(mechanic): add Caddy fragment for mechanic.pptaxi.ru"
Task A3: MinIO CORS policy on bucket pp-inspections
Files:
-
Create:
mechanic-pwa/ops/minio-cors.json -
Step 1: Write CORS policy
Create mechanic-pwa/ops/minio-cors.json:
{
"CORSRules": [
{
"AllowedOrigins": [
"https://mechanic.pptaxi.ru",
"https://crm.pptaxi.ru",
"http://localhost:5173"
],
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "x-amz-meta-*"],
"MaxAgeSeconds": 3600
}
]
}
- Step 2: Apply via
mc(MinIO client) on VDS
# On VDS 100.64.0.12
source /opt/sites/taxi-dashboard/minio.env
mc alias set local https://s3.pptaxi.ru "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
mc anonymous set-json /path/to/mechanic-pwa/ops/minio-cors.json local/pp-inspections
# (For mc < 2024: `mc admin policy set-json` — adjust to installed version)
Expected: Successfully configured cors policy for local/pp-inspections``.
- Step 3: Verify with curl preflight
curl -i -X OPTIONS \
-H "Origin: https://mechanic.pptaxi.ru" \
-H "Access-Control-Request-Method: PUT" \
https://s3.pptaxi.ru/pp-inspections
Expected: HTTP/2 200 and Access-Control-Allow-Origin: https://mechanic.pptaxi.ru.
- Step 4: Commit
git add mechanic-pwa/ops/minio-cors.json
git commit -m "ops(mechanic): MinIO CORS policy for pp-inspections bucket"
Task A4: PostgreSQL migration — three new tables
Files:
-
Create:
backend/alembic/versions/2026_05_16_1200_mechanic_init.py -
Step 1: Generate empty migration
cd /opt/sites/taxi-dashboard/backend
alembic revision -m "mechanic_init"
Expected: created file alembic/versions/<hash>_mechanic_init.py.
Rename or note the file (use date-prefix convention to match this plan: 2026_05_16_1200_mechanic_init.py).
- Step 2: Write
upgrade()anddowngrade()
Replace migration content with:
"""mechanic_init
Revision ID: <as generated>
Revises: <prev>
Create Date: 2026-05-16 12:00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
# revision identifiers, used by Alembic.
revision = "<as generated>"
down_revision = "<previous head>"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"inspections",
sa.Column("id", sa.BigInteger, primary_key=True),
sa.Column("vehicle_id", sa.BigInteger, sa.ForeignKey("vehicles.id"), nullable=False),
sa.Column("type", sa.String(32), nullable=False),
sa.Column("performed_by", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
sa.Column("driver_id", sa.Integer, sa.ForeignKey("users.id"), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(16), nullable=False, server_default="in_progress"),
sa.Column("notes", sa.Text, nullable=True),
sa.Column("prev_inspection_id", sa.BigInteger, sa.ForeignKey("inspections.id"), nullable=True),
sa.Column("meta", JSONB, server_default=sa.text("'{}'::jsonb")),
)
op.create_index("ix_inspections_vehicle_started", "inspections", ["vehicle_id", sa.text("started_at DESC")])
op.create_table(
"inspection_photos",
sa.Column("id", sa.BigInteger, primary_key=True),
sa.Column("inspection_id", sa.BigInteger, sa.ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False),
sa.Column("side", sa.String(16), nullable=False),
sa.Column("slot_index", sa.Integer, server_default="0"),
sa.Column("storage_key", sa.Text, nullable=False),
sa.Column("annotated_key", sa.Text, nullable=True),
sa.Column("thumb_key", sa.Text, nullable=True),
sa.Column("width", sa.Integer, nullable=True),
sa.Column("height", sa.Integer, nullable=True),
sa.Column("bytes", sa.BigInteger, nullable=True),
sa.Column("status", sa.String(16), nullable=False, server_default="pending"), # pending|ready
sa.Column("taken_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("display_order", sa.Integer, server_default="0"),
)
op.create_table(
"damage_markers",
sa.Column("id", sa.BigInteger, primary_key=True),
sa.Column("inspection_id", sa.BigInteger, sa.ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False),
sa.Column("photo_id", sa.BigInteger, sa.ForeignKey("inspection_photos.id"), nullable=True),
sa.Column("side", sa.String(16), nullable=True),
sa.Column("x", sa.Numeric(6, 4), nullable=True),
sa.Column("y", sa.Numeric(6, 4), nullable=True),
sa.Column("polygon", JSONB, nullable=True),
sa.Column("damage_type", sa.String(32), nullable=True),
sa.Column("severity", sa.String(16), nullable=True),
sa.Column("description", sa.Text, nullable=True),
sa.Column("carried_over_from_id", sa.BigInteger, sa.ForeignKey("damage_markers.id"), nullable=True),
sa.Column("resolved", sa.Boolean, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_damage_markers_inspection", "damage_markers", ["inspection_id"])
def downgrade() -> None:
op.drop_index("ix_damage_markers_inspection", table_name="damage_markers")
op.drop_table("damage_markers")
op.drop_table("inspection_photos")
op.drop_index("ix_inspections_vehicle_started", table_name="inspections")
op.drop_table("inspections")
Replace <as generated> and <previous head> with values Alembic assigned in Step 1.
- Step 3: Apply migration to dev DB
alembic upgrade head
Expected: Running upgrade <prev> -> <new>, mechanic_init.
- Step 4: Verify schema in psql
psql $DATABASE_URL -c "\d inspections" -c "\d inspection_photos" -c "\d damage_markers"
Expected: three tables with described columns.
- Step 5: Test downgrade then re-upgrade
alembic downgrade -1
alembic upgrade head
Expected: both succeed without errors.
- Step 6: Commit
git add backend/alembic/versions/2026_05_16_1200_mechanic_init.py
git commit -m "feat(mechanic): add migration for inspections, photos, markers tables"
Phase B — Backend Module Scaffold (1 day)
Task B1: Create empty mechanic module package
Files:
-
Create:
backend/app/mechanic/__init__.py -
Create:
backend/app/mechanic/router.py -
Step 1: Create package with empty router
backend/app/mechanic/__init__.py:
"""Premium Mechanic — PWA backend module for vehicle inspections."""
from .router import router
__all__ = ["router"]
backend/app/mechanic/router.py:
from fastapi import APIRouter
router = APIRouter(prefix="/api/v1/mechanic", tags=["mechanic"])
@router.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
- Step 2: Register in main app
Find backend/app/main.py (or wherever FastAPI() and existing routers live).
Add import + include:
# At top with other imports
from app.mechanic import router as mechanic_router
# Where other routers are included
app.include_router(mechanic_router)
- Step 3: Start app, hit healthz
uvicorn app.main:app --reload --port 8000
# in another terminal
curl http://127.0.0.1:8000/api/v1/mechanic/healthz
Expected: {"status":"ok"}.
- Step 4: Commit
git add backend/app/mechanic/ backend/app/main.py
git commit -m "feat(mechanic): scaffold module with healthz endpoint"
Task B2: SQLAlchemy models
Files:
-
Create:
backend/app/mechanic/models.py -
Step 1: Write failing test for model imports
Create backend/tests/test_mechanic/__init__.py (empty) and backend/tests/test_mechanic/test_models.py:
from app.mechanic.models import Inspection, InspectionPhoto, DamageMarker
def test_inspection_columns():
cols = {c.name for c in Inspection.__table__.columns}
expected = {
"id", "vehicle_id", "type", "performed_by", "driver_id",
"started_at", "finished_at", "status", "notes",
"prev_inspection_id", "meta",
}
assert expected.issubset(cols), f"missing: {expected - cols}"
def test_inspection_photo_columns():
cols = {c.name for c in InspectionPhoto.__table__.columns}
expected = {
"id", "inspection_id", "side", "slot_index", "storage_key",
"annotated_key", "thumb_key", "width", "height", "bytes",
"status", "taken_at", "display_order",
}
assert expected.issubset(cols)
def test_damage_marker_columns():
cols = {c.name for c in DamageMarker.__table__.columns}
expected = {
"id", "inspection_id", "photo_id", "side", "x", "y", "polygon",
"damage_type", "severity", "description",
"carried_over_from_id", "resolved", "created_at",
}
assert expected.issubset(cols)
- Step 2: Run, verify fails
pytest backend/tests/test_mechanic/test_models.py -v
Expected: ModuleNotFoundError: No module named 'app.mechanic.models'.
- Step 3: Implement models
backend/app/mechanic/models.py:
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional
from sqlalchemy import (
BigInteger, Boolean, DateTime, ForeignKey, Integer, Numeric,
String, Text, func, false,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
# Use the same Base as the rest of the project (e.g. from app.db.base)
from app.db.base import Base # adjust import if path differs
class Inspection(Base):
__tablename__ = "inspections"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
vehicle_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("vehicles.id"), nullable=False, index=True)
type: Mapped[str] = mapped_column(String(32), nullable=False)
performed_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
driver_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, server_default="in_progress")
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
prev_inspection_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("inspections.id"), nullable=True)
meta: Mapped[dict[str, Any]] = mapped_column(JSONB, server_default="{}")
photos: Mapped[list["InspectionPhoto"]] = relationship(back_populates="inspection", cascade="all, delete-orphan")
markers: Mapped[list["DamageMarker"]] = relationship(back_populates="inspection", cascade="all, delete-orphan")
prev: Mapped[Optional["Inspection"]] = relationship(remote_side=[id], foreign_keys=[prev_inspection_id])
class InspectionPhoto(Base):
__tablename__ = "inspection_photos"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
inspection_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False, index=True)
side: Mapped[str] = mapped_column(String(16), nullable=False)
slot_index: Mapped[int] = mapped_column(Integer, server_default="0")
storage_key: Mapped[str] = mapped_column(Text, nullable=False)
annotated_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
thumb_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
width: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
height: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
bytes: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, server_default="pending")
taken_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
display_order: Mapped[int] = mapped_column(Integer, server_default="0")
inspection: Mapped[Inspection] = relationship(back_populates="photos")
markers: Mapped[list["DamageMarker"]] = relationship(back_populates="photo")
class DamageMarker(Base):
__tablename__ = "damage_markers"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
inspection_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False, index=True)
photo_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("inspection_photos.id"), nullable=True)
side: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
x: Mapped[Optional[Decimal]] = mapped_column(Numeric(6, 4), nullable=True)
y: Mapped[Optional[Decimal]] = mapped_column(Numeric(6, 4), nullable=True)
polygon: Mapped[Optional[list[dict[str, float]]]] = mapped_column(JSONB, nullable=True)
damage_type: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
severity: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
carried_over_from_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("damage_markers.id"), nullable=True)
resolved: Mapped[bool] = mapped_column(Boolean, server_default=false())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
inspection: Mapped[Inspection] = relationship(back_populates="markers")
photo: Mapped[Optional[InspectionPhoto]] = relationship(back_populates="markers")
carried_over_from: Mapped[Optional["DamageMarker"]] = relationship(remote_side=[id], foreign_keys=[carried_over_from_id])
Note: from app.db.base import Base — adjust import to wherever the project's declarative Base lives (Task A1 finding).
- Step 4: Run test, verify passes
pytest backend/tests/test_mechanic/test_models.py -v
Expected: 3 passed.
- Step 5: Commit
git add backend/app/mechanic/models.py backend/tests/test_mechanic/
git commit -m "feat(mechanic): SQLAlchemy models for inspections + photos + markers"
Task B3: Pydantic schemas
Files:
-
Create:
backend/app/mechanic/schemas.py -
Step 1: Write the file
backend/app/mechanic/schemas.py:
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
InspectionType = Literal["handover", "return", "periodic", "ad-hoc"]
InspectionStatus = Literal["in_progress", "completed", "cancelled"]
PhotoStatus = Literal["pending", "ready"]
PhotoSide = Literal[
"front", "rear", "left", "right",
"interior", "vin", "odometer",
"wheel-fl", "wheel-fr", "wheel-rl", "wheel-rr",
"free",
]
DamageType = Literal["scratch", "dent", "paint", "crack", "missing", "rust", "glass"]
Severity = Literal["cosmetic", "minor", "moderate", "severe"]
class _Base(BaseModel):
model_config = ConfigDict(from_attributes=True)
class VehicleSummary(_Base):
id: int
license_plate: str
vin: Optional[str] = None
make: Optional[str] = None
model: Optional[str] = None
year: Optional[int] = None
last_inspection_at: Optional[datetime] = None
class InspectionCreate(BaseModel):
vehicle_id: int
type: InspectionType
driver_id: Optional[int] = None
class InspectionPatch(BaseModel):
status: Optional[InspectionStatus] = None
notes: Optional[str] = None
finished_at: Optional[datetime] = None
class InspectionPhotoOut(_Base):
id: int
side: PhotoSide
slot_index: int
storage_key: str
thumb_key: Optional[str]
width: Optional[int]
height: Optional[int]
status: PhotoStatus
taken_at: datetime
class MarkerCreate(BaseModel):
photo_id: Optional[int] = None
side: Optional[str] = None
x: Optional[Decimal] = Field(default=None, ge=0, le=1)
y: Optional[Decimal] = Field(default=None, ge=0, le=1)
polygon: Optional[list[dict[str, float]]] = None
damage_type: Optional[DamageType] = None
severity: Optional[Severity] = None
description: Optional[str] = None
class MarkerPatch(BaseModel):
damage_type: Optional[DamageType] = None
severity: Optional[Severity] = None
description: Optional[str] = None
polygon: Optional[list[dict[str, float]]] = None
resolved: Optional[bool] = None
class MarkerOut(_Base):
id: int
inspection_id: int
photo_id: Optional[int]
side: Optional[str]
x: Optional[Decimal]
y: Optional[Decimal]
polygon: Optional[list[dict[str, float]]]
damage_type: Optional[str]
severity: Optional[str]
description: Optional[str]
carried_over_from_id: Optional[int]
resolved: bool
created_at: datetime
class InspectionDetail(_Base):
id: int
vehicle_id: int
type: InspectionType
performed_by: int
driver_id: Optional[int]
started_at: datetime
finished_at: Optional[datetime]
status: InspectionStatus
notes: Optional[str]
prev_inspection_id: Optional[int]
meta: dict[str, Any] = {}
photos: list[InspectionPhotoOut] = []
markers: list[MarkerOut] = []
class PresignedUploadRequest(BaseModel):
side: PhotoSide
slot_index: int = 0
content_type: str
size: int = Field(ge=1, le=20 * 1024 * 1024) # max 20 MB
class PresignedUploadResponse(BaseModel):
photo_id: int
presigned_url: str
fields: dict[str, str]
storage_key: str
expires_at: datetime
class PhotoConfirmRequest(BaseModel):
width: Optional[int] = None
height: Optional[int] = None
actual_size: Optional[int] = None
class MeOut(_Base):
id: int
name: str
email: Optional[str]
permissions: list[str] = []
- Step 2: Commit (no test for schemas alone — covered by endpoint tests)
git add backend/app/mechanic/schemas.py
git commit -m "feat(mechanic): Pydantic schemas"
Task B4: Auth + DB session dependencies
Files:
-
Create:
backend/app/mechanic/deps.py -
Step 1: Write deps
backend/app/mechanic/deps.py:
"""Dependency-injection helpers — reuse existing TaxiDashboard auth + session.
Adjust imports based on Task A1 findings:
- get_db: returns async DB session
- get_current_user: validates JWT cookie, returns User model
"""
from fastapi import Depends, HTTPException, status
from app.auth.deps import get_current_user # adjust path
from app.db.deps import get_db # adjust path
from app.users.models import User # adjust path
def require_mechanic(user: User = Depends(get_current_user)) -> User:
"""Permission guard: only mechanics (dept='Тех. служба') or admins."""
# Replace `dept` / `is_admin` with actual field names from existing User model
if getattr(user, "is_admin", False):
return user
dept = getattr(user, "department", None) or getattr(user, "dept", None)
if dept and dept.lower() in ("тех. служба", "тех служба", "техслужба", "mechanic"):
return user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="mechanic role required")
__all__ = ["get_db", "get_current_user", "require_mechanic"]
- Step 2: Commit
git add backend/app/mechanic/deps.py
git commit -m "feat(mechanic): auth deps wrapper"
Phase C — Backend Read API (1-2 days)
Task C1: GET /me endpoint
Files:
-
Modify:
backend/app/mechanic/router.py -
Create:
backend/tests/test_mechanic/test_me.py -
Step 1: Write failing test
backend/tests/test_mechanic/test_me.py:
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_me_returns_current_mechanic(client: AsyncClient, mechanic_user_token: str):
r = await client.get("/api/v1/mechanic/me",
headers={"Authorization": f"Bearer {mechanic_user_token}"})
assert r.status_code == 200
body = r.json()
assert "id" in body and "name" in body
assert isinstance(body.get("permissions", []), list)
@pytest.mark.asyncio
async def test_me_unauthorized_without_token(client: AsyncClient):
r = await client.get("/api/v1/mechanic/me")
assert r.status_code in (401, 403)
(Assumes existing client and mechanic_user_token fixtures in backend/tests/conftest.py. If not present — extract from existing tests of other modules; do NOT recreate, reuse.)
- Step 2: Verify fails
pytest backend/tests/test_mechanic/test_me.py -v
Expected: 404 (route not found) or import error.
- Step 3: Add endpoint to router
In backend/app/mechanic/router.py, append:
from fastapi import Depends
from app.mechanic.deps import require_mechanic
from app.mechanic.schemas import MeOut
@router.get("/me", response_model=MeOut)
async def me(user=Depends(require_mechanic)):
perms = []
if getattr(user, "is_admin", False):
perms.append("admin")
perms.append("mechanic:inspect")
return MeOut(
id=user.id,
name=getattr(user, "full_name", None) or getattr(user, "name", "") or user.email or f"user#{user.id}",
email=getattr(user, "email", None),
permissions=perms,
)
- Step 4: Verify passes
pytest backend/tests/test_mechanic/test_me.py -v
Expected: 2 passed.
- Step 5: Commit
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_me.py
git commit -m "feat(mechanic): GET /me endpoint"
Task C2: GET /vehicles — list with search
Files:
-
Modify:
backend/app/mechanic/router.py -
Create:
backend/app/mechanic/service.py -
Create:
backend/tests/test_mechanic/test_vehicles.py -
Step 1: Write failing test
backend/tests/test_mechanic/test_vehicles.py:
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_vehicles_list_returns_items(client, mechanic_user_token, sample_vehicles):
r = await client.get("/api/v1/mechanic/vehicles?limit=10",
headers={"Authorization": f"Bearer {mechanic_user_token}"})
assert r.status_code == 200
data = r.json()
assert "items" in data
assert len(data["items"]) >= 1
item = data["items"][0]
assert "id" in item and "license_plate" in item
@pytest.mark.asyncio
async def test_vehicles_search_by_plate(client, mechanic_user_token, sample_vehicles):
plate = sample_vehicles[0].license_plate[:3]
r = await client.get(f"/api/v1/mechanic/vehicles?q={plate}",
headers={"Authorization": f"Bearer {mechanic_user_token}"})
assert r.status_code == 200
assert any(plate in v["license_plate"] for v in r.json()["items"])
The sample_vehicles fixture must exist or be added to conftest.py (creates ≥3 vehicles in DB before test, cleans up after). If not present — add it.
- Step 2: Verify fails
pytest backend/tests/test_mechanic/test_vehicles.py -v
- Step 3: Implement service
Create backend/app/mechanic/service.py:
from __future__ import annotations
from typing import Optional
from sqlalchemy import select, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.vehicles.models import Vehicle # adjust import to existing path
from app.mechanic.models import Inspection
async def search_vehicles(
session: AsyncSession,
q: Optional[str] = None,
limit: int = 20,
) -> list[Vehicle]:
stmt = select(Vehicle).limit(limit)
if q:
like = f"%{q}%"
stmt = stmt.where(
or_(
Vehicle.license_plate.ilike(like),
Vehicle.vin.ilike(like) if hasattr(Vehicle, "vin") else False,
)
)
stmt = stmt.order_by(Vehicle.license_plate.asc())
result = await session.execute(stmt)
return list(result.scalars().all())
async def last_inspection_at(session: AsyncSession, vehicle_id: int):
stmt = select(func.max(Inspection.finished_at)).where(
Inspection.vehicle_id == vehicle_id,
Inspection.status == "completed",
)
return (await session.execute(stmt)).scalar_one_or_none()
- Step 4: Add endpoint to router
In backend/app/mechanic/router.py, append:
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.mechanic.deps import get_db
from app.mechanic import service
from app.mechanic.schemas import VehicleSummary
@router.get("/vehicles", response_model=dict)
async def list_vehicles(
q: Optional[str] = None,
limit: int = 20,
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
vehicles = await service.search_vehicles(session, q=q, limit=limit)
out = []
for v in vehicles:
out.append(VehicleSummary(
id=v.id,
license_plate=v.license_plate,
vin=getattr(v, "vin", None),
make=getattr(v, "make", None),
model=getattr(v, "model", None),
year=getattr(v, "year", None),
last_inspection_at=await service.last_inspection_at(session, v.id),
))
return {"items": [item.model_dump(mode="json") for item in out]}
- Step 5: Verify passes + commit
pytest backend/tests/test_mechanic/test_vehicles.py -v
git add backend/app/mechanic/ backend/tests/test_mechanic/test_vehicles.py
git commit -m "feat(mechanic): GET /vehicles search endpoint"
Task C3: GET /vehicles/{id} — detail
Files:
-
Modify:
backend/app/mechanic/router.py -
Modify:
backend/app/mechanic/service.py -
Append to:
backend/tests/test_mechanic/test_vehicles.py -
Step 1: Append test
@pytest.mark.asyncio
async def test_vehicle_detail(client, mechanic_user_token, sample_vehicles):
vid = sample_vehicles[0].id
r = await client.get(f"/api/v1/mechanic/vehicles/{vid}",
headers={"Authorization": f"Bearer {mechanic_user_token}"})
assert r.status_code == 200
body = r.json()
assert body["id"] == vid
assert "license_plate" in body
assert "recent_inspections" in body
@pytest.mark.asyncio
async def test_vehicle_detail_404(client, mechanic_user_token):
r = await client.get("/api/v1/mechanic/vehicles/999999999",
headers={"Authorization": f"Bearer {mechanic_user_token}"})
assert r.status_code == 404
- Step 2: Verify fails
pytest backend/tests/test_mechanic/test_vehicles.py::test_vehicle_detail -v
- Step 3: Add service method
Append to service.py:
from app.mechanic.models import InspectionPhoto
async def get_vehicle_or_404(session: AsyncSession, vehicle_id: int) -> Vehicle:
obj = await session.get(Vehicle, vehicle_id)
if not obj:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="vehicle not found")
return obj
async def recent_inspections(session: AsyncSession, vehicle_id: int, limit: int = 5) -> list[Inspection]:
stmt = (
select(Inspection)
.where(Inspection.vehicle_id == vehicle_id)
.order_by(Inspection.started_at.desc())
.limit(limit)
)
return list((await session.execute(stmt)).scalars().all())
- Step 4: Add endpoint
Append to router.py:
@router.get("/vehicles/{vehicle_id}")
async def vehicle_detail(
vehicle_id: int,
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
v = await service.get_vehicle_or_404(session, vehicle_id)
insps = await service.recent_inspections(session, vehicle_id, limit=10)
return {
"id": v.id,
"license_plate": v.license_plate,
"vin": getattr(v, "vin", None),
"make": getattr(v, "make", None),
"model": getattr(v, "model", None),
"year": getattr(v, "year", None),
"recent_inspections": [
{
"id": i.id, "type": i.type, "status": i.status,
"started_at": i.started_at.isoformat(),
"finished_at": i.finished_at.isoformat() if i.finished_at else None,
}
for i in insps
],
}
- Step 5: Verify + commit
pytest backend/tests/test_mechanic/test_vehicles.py -v
git add backend/app/mechanic/ backend/tests/test_mechanic/test_vehicles.py
git commit -m "feat(mechanic): GET /vehicles/{id} detail endpoint"
Phase D — Backend Inspection API (1-2 days)
Task D1: POST /inspections — create new
Files:
-
Modify:
backend/app/mechanic/router.py -
Modify:
backend/app/mechanic/service.py -
Create:
backend/tests/test_mechanic/test_inspections.py -
Step 1: Write failing test
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_create_inspection_handover(client, mechanic_user_token, sample_vehicles):
vid = sample_vehicles[0].id
r = await client.post("/api/v1/mechanic/inspections",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
json={"vehicle_id": vid, "type": "handover"})
assert r.status_code == 201
body = r.json()
assert body["vehicle_id"] == vid
assert body["type"] == "handover"
assert body["status"] == "in_progress"
assert "id" in body
@pytest.mark.asyncio
async def test_create_inspection_return_copies_markers(
client, mechanic_user_token, sample_vehicle_with_prev_inspection
):
"""If prev inspection has markers, new return-inspection inherits them."""
vid, prev_marker_count = sample_vehicle_with_prev_inspection
r = await client.post("/api/v1/mechanic/inspections",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
json={"vehicle_id": vid, "type": "return"})
assert r.status_code == 201
new_id = r.json()["id"]
r2 = await client.get(f"/api/v1/mechanic/inspections/{new_id}",
headers={"Authorization": f"Bearer {mechanic_user_token}"})
detail = r2.json()
assert len(detail["markers"]) == prev_marker_count
assert all(m["carried_over_from_id"] for m in detail["markers"])
- Step 2: Verify fails
pytest backend/tests/test_mechanic/test_inspections.py -v
- Step 3: Implement service
Append to service.py:
from app.mechanic.models import DamageMarker
async def latest_completed_inspection(session: AsyncSession, vehicle_id: int) -> Optional[Inspection]:
stmt = (
select(Inspection)
.where(Inspection.vehicle_id == vehicle_id, Inspection.status == "completed")
.order_by(Inspection.finished_at.desc())
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def start_inspection(
session: AsyncSession,
*,
vehicle_id: int,
type: str,
performed_by: int,
driver_id: Optional[int] = None,
) -> Inspection:
prev = None
if type == "return":
prev = await latest_completed_inspection(session, vehicle_id)
insp = Inspection(
vehicle_id=vehicle_id,
type=type,
performed_by=performed_by,
driver_id=driver_id,
prev_inspection_id=prev.id if prev else None,
)
session.add(insp)
await session.flush() # assigns id
if prev:
# Copy markers from prev as carried-over
prev_markers = (await session.execute(
select(DamageMarker).where(DamageMarker.inspection_id == prev.id)
)).scalars().all()
for m in prev_markers:
session.add(DamageMarker(
inspection_id=insp.id,
photo_id=None, # original photo belongs to prev inspection
side=m.side,
x=m.x, y=m.y, polygon=m.polygon,
damage_type=m.damage_type, severity=m.severity,
description=m.description,
carried_over_from_id=m.id,
resolved=False,
))
await session.commit()
await session.refresh(insp)
return insp
- Step 4: Add endpoint
Append to router.py:
from fastapi import status as http_status
from app.mechanic.schemas import InspectionCreate, InspectionDetail
@router.post("/inspections", status_code=http_status.HTTP_201_CREATED, response_model=InspectionDetail)
async def create_inspection(
payload: InspectionCreate,
session: AsyncSession = Depends(get_db),
user=Depends(require_mechanic),
):
# Ensure vehicle exists
await service.get_vehicle_or_404(session, payload.vehicle_id)
insp = await service.start_inspection(
session,
vehicle_id=payload.vehicle_id,
type=payload.type,
performed_by=user.id,
driver_id=payload.driver_id,
)
return insp
- Step 5: Verify + commit
pytest backend/tests/test_mechanic/test_inspections.py::test_create_inspection_handover -v
git add backend/app/mechanic/ backend/tests/test_mechanic/test_inspections.py
git commit -m "feat(mechanic): POST /inspections endpoint"
(Second test depends on Task D2.)
Task D2: GET /inspections/{id} — full detail with photos & markers
Files:
-
Modify:
backend/app/mechanic/router.py -
Append to:
backend/tests/test_mechanic/test_inspections.py -
Step 1: Append test
@pytest.mark.asyncio
async def test_get_inspection_detail_includes_photos_and_markers(
client, mechanic_user_token, sample_inspection_with_photo_and_marker
):
insp_id = sample_inspection_with_photo_and_marker
r = await client.get(f"/api/v1/mechanic/inspections/{insp_id}",
headers={"Authorization": f"Bearer {mechanic_user_token}"})
assert r.status_code == 200
body = r.json()
assert body["id"] == insp_id
assert len(body["photos"]) >= 1
assert len(body["markers"]) >= 1
-
Step 2: Verify fails
-
Step 3: Add endpoint
Append to router.py:
from sqlalchemy.orm import selectinload
from app.mechanic.models import Inspection
@router.get("/inspections/{inspection_id}", response_model=InspectionDetail)
async def get_inspection(
inspection_id: int,
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
stmt = (
select(Inspection)
.where(Inspection.id == inspection_id)
.options(selectinload(Inspection.photos), selectinload(Inspection.markers))
)
insp = (await session.execute(stmt)).scalar_one_or_none()
if not insp:
from fastapi import HTTPException
raise HTTPException(404, "inspection not found")
return insp
- Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_inspections.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_inspections.py
git commit -m "feat(mechanic): GET /inspections/{id} detail endpoint"
Now also test_create_inspection_return_copies_markers should pass.
Task D3: PATCH /inspections/{id} — update status/notes
Files:
-
Modify:
backend/app/mechanic/router.py -
Append to:
backend/tests/test_mechanic/test_inspections.py -
Step 1: Append test
@pytest.mark.asyncio
async def test_finalize_inspection(client, mechanic_user_token, sample_inspection):
insp_id = sample_inspection.id
r = await client.patch(
f"/api/v1/mechanic/inspections/{insp_id}",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
json={"status": "completed", "notes": "All good."},
)
assert r.status_code == 200
assert r.json()["status"] == "completed"
-
Step 2: Verify fails
-
Step 3: Implement
Append to router.py:
from datetime import datetime, timezone
from app.mechanic.schemas import InspectionPatch
@router.patch("/inspections/{inspection_id}", response_model=InspectionDetail)
async def patch_inspection(
inspection_id: int,
payload: InspectionPatch,
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
insp = await session.get(Inspection, inspection_id)
if not insp:
from fastapi import HTTPException
raise HTTPException(404, "inspection not found")
data = payload.model_dump(exclude_unset=True)
if data.get("status") == "completed" and "finished_at" not in data:
data["finished_at"] = datetime.now(timezone.utc)
for k, v in data.items():
setattr(insp, k, v)
await session.commit()
await session.refresh(insp)
return insp
- Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_inspections.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_inspections.py
git commit -m "feat(mechanic): PATCH /inspections/{id}"
Phase E — Backend Photo Storage (2 days)
Task E1: MinIO async client wrapper
Files:
-
Create:
backend/app/mechanic/storage.py -
Create:
backend/tests/test_mechanic/test_storage.py -
Step 1: Write failing test
import pytest
from app.mechanic.storage import build_storage_key, get_minio_client
def test_build_storage_key_inspection_original():
key = build_storage_key(inspection_id=42, photo_id=7, variant="original", ext="jpg")
assert key == "inspections/42/original/7.jpg"
def test_build_storage_key_thumb():
key = build_storage_key(inspection_id=42, photo_id=7, variant="thumb", ext="jpg")
assert key == "inspections/42/thumb/7.jpg"
@pytest.mark.asyncio
async def test_get_minio_client_returns_session(monkeypatch):
monkeypatch.setenv("MINIO_ENDPOINT", "https://s3.pptaxi.ru")
monkeypatch.setenv("MINIO_ACCESS_KEY", "key")
monkeypatch.setenv("MINIO_SECRET_KEY", "secret")
async with get_minio_client() as client:
# client should expose generate_presigned_post via aiobotocore (smoke check)
assert hasattr(client, "generate_presigned_post")
- Step 2: Implement
backend/app/mechanic/storage.py:
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from aiobotocore.session import get_session
from botocore.client import Config
BUCKET = os.environ.get("MINIO_BUCKET", "pp-inspections")
ENDPOINT = os.environ.get("MINIO_ENDPOINT", "https://s3.pptaxi.ru")
ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "")
SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "")
REGION = os.environ.get("MINIO_REGION", "us-east-1")
PRESIGN_TTL = int(os.environ.get("MINIO_PRESIGN_TTL", "3600")) # 1 hour
def build_storage_key(*, inspection_id: int, photo_id: int, variant: str, ext: str) -> str:
assert variant in {"original", "annotated", "thumb"}
ext = ext.lstrip(".").lower()
return f"inspections/{inspection_id}/{variant}/{photo_id}.{ext}"
@asynccontextmanager
async def get_minio_client():
session = get_session()
async with session.create_client(
"s3",
endpoint_url=ENDPOINT,
aws_access_key_id=ACCESS_KEY,
aws_secret_access_key=SECRET_KEY,
region_name=REGION,
config=Config(signature_version="s3v4"),
) as client:
yield client
async def presigned_upload(
*,
storage_key: str,
content_type: str,
max_bytes: int = 20 * 1024 * 1024,
) -> tuple[dict, datetime]:
"""Return ({'url': ..., 'fields': {...}}, expires_at)."""
async with get_minio_client() as client:
resp = await client.generate_presigned_post(
Bucket=BUCKET,
Key=storage_key,
Fields={"Content-Type": content_type},
Conditions=[
{"Content-Type": content_type},
["content-length-range", 1, max_bytes],
],
ExpiresIn=PRESIGN_TTL,
)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=PRESIGN_TTL)
return resp, expires_at
async def presigned_get(storage_key: str) -> str:
async with get_minio_client() as client:
return await client.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET, "Key": storage_key},
ExpiresIn=PRESIGN_TTL,
)
async def head_object(storage_key: str) -> dict | None:
async with get_minio_client() as client:
try:
return await client.head_object(Bucket=BUCKET, Key=storage_key)
except Exception:
return None
- Step 3: Verify + commit
pytest backend/tests/test_mechanic/test_storage.py -v
git add backend/app/mechanic/storage.py backend/tests/test_mechanic/test_storage.py
git commit -m "feat(mechanic): MinIO storage wrapper with presigned upload/get"
Task E2: POST /inspections/{id}/photos/upload-url — presigned upload URL
Files:
-
Modify:
backend/app/mechanic/router.py -
Modify:
backend/app/mechanic/service.py -
Create:
backend/tests/test_mechanic/test_photos.py -
Step 1: Write failing test
import pytest
@pytest.mark.asyncio
async def test_request_upload_url(client, mechanic_user_token, sample_inspection):
insp_id = sample_inspection.id
r = await client.post(
f"/api/v1/mechanic/inspections/{insp_id}/photos/upload-url",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
json={"side": "front", "slot_index": 0,
"content_type": "image/jpeg", "size": 1024 * 500},
)
assert r.status_code == 200
body = r.json()
assert "photo_id" in body
assert body["presigned_url"].startswith("http")
assert "fields" in body
assert "key" in body["fields"] or "Key" in body["fields"]
@pytest.mark.asyncio
async def test_request_upload_url_rejects_oversize(client, mechanic_user_token, sample_inspection):
r = await client.post(
f"/api/v1/mechanic/inspections/{sample_inspection.id}/photos/upload-url",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
json={"side": "front", "content_type": "image/jpeg", "size": 50 * 1024 * 1024},
)
assert r.status_code in (400, 422)
-
Step 2: Verify fails
-
Step 3: Implement service method
Append to service.py:
from app.mechanic.models import InspectionPhoto
from app.mechanic.storage import presigned_upload, build_storage_key
async def create_photo_placeholder(
session: AsyncSession,
*,
inspection_id: int,
side: str,
slot_index: int,
content_type: str,
size: int,
):
# Create the DB row first to obtain photo_id
ext = "jpg" if content_type in ("image/jpeg", "image/jpg") else content_type.split("/")[-1]
photo = InspectionPhoto(
inspection_id=inspection_id,
side=side,
slot_index=slot_index,
storage_key="pending",
status="pending",
bytes=size,
)
session.add(photo)
await session.flush() # gets photo.id
key = build_storage_key(
inspection_id=inspection_id, photo_id=photo.id,
variant="original", ext=ext,
)
photo.storage_key = key
await session.commit()
await session.refresh(photo)
presign, expires_at = await presigned_upload(
storage_key=key, content_type=content_type, max_bytes=size,
)
return photo, presign, expires_at
- Step 4: Add endpoint
Append to router.py:
from app.mechanic.schemas import PresignedUploadRequest, PresignedUploadResponse
@router.post(
"/inspections/{inspection_id}/photos/upload-url",
response_model=PresignedUploadResponse,
)
async def request_photo_upload_url(
inspection_id: int,
payload: PresignedUploadRequest,
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
insp = await session.get(Inspection, inspection_id)
if not insp:
from fastapi import HTTPException
raise HTTPException(404, "inspection not found")
photo, presign, expires_at = await service.create_photo_placeholder(
session,
inspection_id=inspection_id,
side=payload.side, slot_index=payload.slot_index,
content_type=payload.content_type, size=payload.size,
)
return PresignedUploadResponse(
photo_id=photo.id,
presigned_url=presign["url"],
fields=presign["fields"],
storage_key=photo.storage_key,
expires_at=expires_at,
)
- Step 5: Verify + commit
pytest backend/tests/test_mechanic/test_photos.py -v
git add backend/app/mechanic/ backend/tests/test_mechanic/test_photos.py
git commit -m "feat(mechanic): POST /photos/upload-url (presigned)"
Task E3: POST /photos/{id}/confirm — mark as ready
Files:
-
Modify:
backend/app/mechanic/router.py -
Append to:
backend/tests/test_mechanic/test_photos.py -
Step 1: Append test
@pytest.mark.asyncio
async def test_confirm_photo_after_upload(client, mechanic_user_token, uploaded_photo_pending):
photo_id = uploaded_photo_pending.id # fixture that simulates upload in MinIO
r = await client.post(
f"/api/v1/mechanic/photos/{photo_id}/confirm",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
json={"width": 1920, "height": 1080, "actual_size": 524288},
)
assert r.status_code == 200
assert r.json()["status"] == "ready"
The fixture uploaded_photo_pending creates a pending InspectionPhoto row and (for the test) PUTs a small object into MinIO under its storage_key via the same client (or stubs head_object to return non-None).
-
Step 2: Verify fails
-
Step 3: Implement
Append to router.py:
from app.mechanic.schemas import PhotoConfirmRequest
from app.mechanic.models import InspectionPhoto
from app.mechanic.storage import head_object
@router.post("/photos/{photo_id}/confirm")
async def confirm_photo(
photo_id: int,
payload: PhotoConfirmRequest,
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
photo = await session.get(InspectionPhoto, photo_id)
if not photo:
from fastapi import HTTPException
raise HTTPException(404, "photo not found")
# Verify object actually exists in MinIO
meta = await head_object(photo.storage_key)
if not meta:
from fastapi import HTTPException
raise HTTPException(409, "object not uploaded yet")
photo.status = "ready"
if payload.width:
photo.width = payload.width
if payload.height:
photo.height = payload.height
if payload.actual_size:
photo.bytes = payload.actual_size
await session.commit()
await session.refresh(photo)
# Enqueue celery task for thumbnail (next task) — for now, fire-and-forget
# from app.mechanic.tasks import make_thumbnail
# make_thumbnail.delay(photo.id)
return {
"id": photo.id, "storage_key": photo.storage_key, "thumb_key": photo.thumb_key,
"status": photo.status,
}
- Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_photos.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_photos.py
git commit -m "feat(mechanic): POST /photos/{id}/confirm"
Task E4: GET /photos/{id} — 302 redirect to presigned URL
Files:
-
Modify:
backend/app/mechanic/router.py -
Append to:
backend/tests/test_mechanic/test_photos.py -
Step 1: Append test
@pytest.mark.asyncio
async def test_get_photo_returns_redirect(client, mechanic_user_token, uploaded_photo_ready):
r = await client.get(
f"/api/v1/mechanic/photos/{uploaded_photo_ready.id}",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
follow_redirects=False,
)
assert r.status_code == 302
assert r.headers["location"].startswith("https://s3.pptaxi.ru/pp-inspections/")
-
Step 2: Verify fails
-
Step 3: Implement
Append to router.py:
from fastapi.responses import RedirectResponse
from app.mechanic.storage import presigned_get
@router.get("/photos/{photo_id}")
async def get_photo(
photo_id: int,
variant: str = "original", # 'original' | 'thumb' | 'annotated'
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
photo = await session.get(InspectionPhoto, photo_id)
if not photo:
from fastapi import HTTPException
raise HTTPException(404, "photo not found")
key_map = {
"original": photo.storage_key,
"thumb": photo.thumb_key,
"annotated": photo.annotated_key,
}
key = key_map.get(variant) or photo.storage_key
url = await presigned_get(key)
return RedirectResponse(url, status_code=302)
- Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_photos.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_photos.py
git commit -m "feat(mechanic): GET /photos/{id} returns 302 to presigned URL"
Task E5: Celery task for thumbnail generation
Files:
-
Create:
backend/app/mechanic/tasks.py -
Create:
backend/tests/test_mechanic/test_tasks.py -
Step 1: Write failing test
import pytest
from PIL import Image
import io
from app.mechanic.tasks import _make_thumbnail_sync
def test_make_thumbnail_sync_downsizes():
# build 4000×3000 sample
img = Image.new("RGB", (4000, 3000), color=(128, 64, 32))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=80)
src_bytes = buf.getvalue()
thumb_bytes = _make_thumbnail_sync(src_bytes, max_side=512)
out = Image.open(io.BytesIO(thumb_bytes))
assert max(out.size) == 512
-
Step 2: Verify fails
-
Step 3: Implement
backend/app/mechanic/tasks.py:
from __future__ import annotations
import io
from PIL import Image, ImageOps
# If the project uses Celery and has a configured app, use that. Otherwise this
# task can be invoked synchronously after upload-confirm.
try:
from app.celery_app import celery_app # adjust import per project
HAS_CELERY = True
except ImportError:
celery_app = None
HAS_CELERY = False
def _make_thumbnail_sync(src: bytes, max_side: int = 512) -> bytes:
img = Image.open(io.BytesIO(src))
img = ImageOps.exif_transpose(img)
img.thumbnail((max_side, max_side))
out = io.BytesIO()
img.convert("RGB").save(out, format="JPEG", quality=85)
return out.getvalue()
async def make_thumbnail_async(photo_id: int) -> None:
"""Download original, make thumbnail, upload to thumb_key, update DB."""
from app.mechanic.storage import get_minio_client, build_storage_key, BUCKET
from app.mechanic.models import InspectionPhoto
from app.db.deps import async_session_factory # adjust path
async with async_session_factory() as session:
photo = await session.get(InspectionPhoto, photo_id)
if not photo or photo.status != "ready":
return
async with get_minio_client() as client:
resp = await client.get_object(Bucket=BUCKET, Key=photo.storage_key)
async with resp["Body"] as stream:
src_bytes = await stream.read()
thumb_bytes = _make_thumbnail_sync(src_bytes, max_side=512)
thumb_key = build_storage_key(
inspection_id=photo.inspection_id, photo_id=photo.id,
variant="thumb", ext="jpg",
)
async with get_minio_client() as client:
await client.put_object(
Bucket=BUCKET, Key=thumb_key,
Body=thumb_bytes, ContentType="image/jpeg",
)
photo.thumb_key = thumb_key
await session.commit()
if HAS_CELERY:
@celery_app.task(name="mechanic.make_thumbnail")
def make_thumbnail_task(photo_id: int):
import asyncio
asyncio.run(make_thumbnail_async(photo_id))
- Step 4: Wire into confirm endpoint
In router.py, in confirm_photo(), replace the commented # Enqueue celery task block:
from app.mechanic.tasks import make_thumbnail_async, HAS_CELERY
if HAS_CELERY:
from app.mechanic.tasks import make_thumbnail_task
make_thumbnail_task.delay(photo.id)
else:
# dev/test: do it inline async (best-effort)
import asyncio
asyncio.create_task(make_thumbnail_async(photo.id))
- Step 5: Verify + commit
pytest backend/tests/test_mechanic/test_tasks.py -v
git add backend/app/mechanic/tasks.py backend/app/mechanic/router.py backend/tests/test_mechanic/test_tasks.py
git commit -m "feat(mechanic): thumbnail generation task"
Phase F — Backend Markers API (0.5 day)
Task F1: POST /inspections/{id}/markers
Files:
-
Modify:
backend/app/mechanic/router.py -
Create:
backend/tests/test_mechanic/test_markers.py -
Step 1: Write failing test
import pytest
@pytest.mark.asyncio
async def test_create_marker_point_on_photo(
client, mechanic_user_token, sample_inspection_with_photo
):
insp_id, photo_id = sample_inspection_with_photo
r = await client.post(
f"/api/v1/mechanic/inspections/{insp_id}/markers",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
json={
"photo_id": photo_id, "side": "front",
"x": 0.35, "y": 0.62,
"damage_type": "scratch", "severity": "minor",
"description": "На бампере",
},
)
assert r.status_code == 201
body = r.json()
assert body["damage_type"] == "scratch"
assert body["photo_id"] == photo_id
-
Step 2: Verify fails
-
Step 3: Implement
Append to router.py:
from app.mechanic.schemas import MarkerCreate, MarkerOut, MarkerPatch
from app.mechanic.models import DamageMarker
@router.post(
"/inspections/{inspection_id}/markers",
status_code=http_status.HTTP_201_CREATED,
response_model=MarkerOut,
)
async def create_marker(
inspection_id: int,
payload: MarkerCreate,
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
if not await session.get(Inspection, inspection_id):
from fastapi import HTTPException
raise HTTPException(404, "inspection not found")
m = DamageMarker(
inspection_id=inspection_id,
photo_id=payload.photo_id,
side=payload.side,
x=payload.x, y=payload.y, polygon=payload.polygon,
damage_type=payload.damage_type,
severity=payload.severity,
description=payload.description,
)
session.add(m)
await session.commit()
await session.refresh(m)
return m
- Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_markers.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_markers.py
git commit -m "feat(mechanic): POST markers endpoint"
Task F2: PATCH /markers/{id} + DELETE /markers/{id}
Files:
-
Modify:
backend/app/mechanic/router.py -
Append to:
backend/tests/test_mechanic/test_markers.py -
Step 1: Append tests
@pytest.mark.asyncio
async def test_patch_marker_severity(client, mechanic_user_token, sample_marker):
r = await client.patch(
f"/api/v1/mechanic/markers/{sample_marker.id}",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
json={"severity": "moderate", "resolved": True},
)
assert r.status_code == 200
body = r.json()
assert body["severity"] == "moderate"
assert body["resolved"] is True
@pytest.mark.asyncio
async def test_delete_marker(client, mechanic_user_token, sample_marker):
r = await client.delete(
f"/api/v1/mechanic/markers/{sample_marker.id}",
headers={"Authorization": f"Bearer {mechanic_user_token}"},
)
assert r.status_code == 204
-
Step 2: Verify fails
-
Step 3: Implement
Append to router.py:
@router.patch("/markers/{marker_id}", response_model=MarkerOut)
async def patch_marker(
marker_id: int,
payload: MarkerPatch,
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
m = await session.get(DamageMarker, marker_id)
if not m:
from fastapi import HTTPException
raise HTTPException(404, "marker not found")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(m, k, v)
await session.commit()
await session.refresh(m)
return m
@router.delete("/markers/{marker_id}", status_code=http_status.HTTP_204_NO_CONTENT)
async def delete_marker(
marker_id: int,
session: AsyncSession = Depends(get_db),
_user=Depends(require_mechanic),
):
m = await session.get(DamageMarker, marker_id)
if not m:
from fastapi import HTTPException
raise HTTPException(404, "marker not found")
await session.delete(m)
await session.commit()
return None
- Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_markers.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_markers.py
git commit -m "feat(mechanic): PATCH and DELETE markers"
Phase G — Frontend Scaffold (1-2 days)
Task G1: Init Vite + React + TS project
Files:
-
Create: entire
mechanic-pwa/frontend/directory -
Step 1: Run scaffolder
cd c:/NewProject/PremiumDriverApp/mechanic-pwa
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install
Expected: scaffold created, dev server starts on npm run dev.
- Step 2: Install runtime deps
npm install react-router-dom@^7 @tanstack/react-query@^5 zustand@^5 \
ky@^1 zod@^3 react-hook-form@^7 \
html5-qrcode@^2 vite-plugin-pwa@^0.20
npm install -D @types/node tailwindcss postcss autoprefixer
- Step 3: Init Tailwind
npx tailwindcss init -p
Edit tailwind.config.js:
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
Edit src/index.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
font-family: 'Inter', system-ui, sans-serif;
}
- Step 4: Smoke run
npm run dev
Visit http://localhost:5173. Expected: Vite welcome page with Inter font.
- Step 5: Commit
git add mechanic-pwa/frontend
git commit -m "feat(mechanic-pwa): scaffold Vite + React + TS + Tailwind"
Task G2: shadcn/ui setup
Files:
-
Modify:
mechanic-pwa/frontend/components.json,src/lib/utils.ts, etc. -
Step 1: Init shadcn
cd mechanic-pwa/frontend
npx shadcn@latest init -d
Use defaults: style=default, color=slate (cream-friendly).
- Step 2: Add starter components
npx shadcn@latest add button card input label sonner
- Step 3: Commit
git add mechanic-pwa/frontend
git commit -m "feat(mechanic-pwa): add shadcn/ui with button/card/input/label/sonner"
Task G3: PWA manifest + Service Worker
Files:
-
Create:
mechanic-pwa/frontend/public/manifest.webmanifest -
Create:
mechanic-pwa/frontend/public/icons/icon-192.png(placeholder, plain crest) -
Create:
mechanic-pwa/frontend/public/icons/icon-512.png -
Modify:
mechanic-pwa/frontend/vite.config.ts -
Modify:
mechanic-pwa/frontend/index.html -
Step 1: Write manifest
public/manifest.webmanifest:
{
"name": "Premium Механик",
"short_name": "Mechanic",
"start_url": "/",
"display": "standalone",
"background_color": "#f5f1ea",
"theme_color": "#2a2a2a",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
- Step 2: Configure vite-plugin-pwa
vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import path from "node:path";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["icons/*.png"],
manifest: false, // we ship our own
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
runtimeCaching: [
{
urlPattern: /\/api\//,
handler: "NetworkOnly",
},
],
},
}),
],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
server: { port: 5173 },
});
- Step 3: Link manifest in
index.html
Add inside <head>:
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#2a2a2a" />
- Step 4: Add 192/512 PNG icons (placeholder)
For now any 192×192 and 512×512 PNG is fine. Generate via:
# Use any tool; quick option with imagemagick / online generator
# placeholder solid-colored squares
convert -size 192x192 xc:'#f5f1ea' -gravity center -pointsize 72 -fill '#2a2a2a' \
-annotate 0 'PM' public/icons/icon-192.png
convert -size 512x512 xc:'#f5f1ea' -gravity center -pointsize 192 -fill '#2a2a2a' \
-annotate 0 'PM' public/icons/icon-512.png
- Step 5: Build + smoke check
npm run build
npm run preview
Open in browser → DevTools → Application → Manifest → should show Premium Механик with both icon sizes.
- Step 6: Commit
git add mechanic-pwa/frontend
git commit -m "feat(mechanic-pwa): PWA manifest + Service Worker"
Task G4: API client with auth interceptor
Files:
-
Create:
mechanic-pwa/frontend/src/api/client.ts -
Create:
mechanic-pwa/frontend/src/store/auth.ts -
Step 1: Write auth store
src/store/auth.ts:
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AuthState {
token: string | null;
setToken: (t: string | null) => void;
}
export const useAuth = create<AuthState>()(
persist(
(set) => ({
token: null,
setToken: (t) => set({ token: t }),
}),
{ name: "mechanic-auth" }
)
);
- Step 2: Write API client
src/api/client.ts:
import ky, { type KyInstance } from "ky";
import { useAuth } from "@/store/auth";
const API_BASE = import.meta.env.VITE_API_BASE ?? "/api/v1/mechanic";
export const api: KyInstance = ky.create({
prefixUrl: API_BASE,
timeout: 30_000,
hooks: {
beforeRequest: [
(request) => {
const token = useAuth.getState().token;
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
},
],
beforeError: [
(error) => {
if (error.response?.status === 401) {
useAuth.getState().setToken(null);
}
return error;
},
],
},
});
- Step 3: Commit
git add mechanic-pwa/frontend/src/api mechanic-pwa/frontend/src/store
git commit -m "feat(mechanic-pwa): api client + auth store"
Task G5: Routing skeleton
Files:
-
Modify:
mechanic-pwa/frontend/src/main.tsx -
Modify:
mechanic-pwa/frontend/src/App.tsx -
Step 1: Write
main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster richColors position="top-center" />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
- Step 2: Write
App.tsxwith route skeleton
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "@/store/auth";
import Login from "@/pages/Login";
import Home from "@/pages/Home";
import VehicleCard from "@/pages/VehicleCard";
import InspectionEditor from "@/pages/InspectionEditor";
import InspectionReview from "@/pages/InspectionReview";
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = useAuth((s) => s.token);
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={<RequireAuth><Home /></RequireAuth>}
/>
<Route
path="/vehicles/:id"
element={<RequireAuth><VehicleCard /></RequireAuth>}
/>
<Route
path="/inspections/:id/edit"
element={<RequireAuth><InspectionEditor /></RequireAuth>}
/>
<Route
path="/inspections/:id"
element={<RequireAuth><InspectionReview /></RequireAuth>}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
- Step 3: Stub page components
Create placeholder files for each page:
src/pages/Login.tsx:
export default function Login() { return <div className="p-8">Login (stub)</div>; }
src/pages/Home.tsx:
export default function Home() { return <div className="p-8">Home (stub)</div>; }
src/pages/VehicleCard.tsx:
export default function VehicleCard() { return <div className="p-8">VehicleCard (stub)</div>; }
src/pages/InspectionEditor.tsx:
export default function InspectionEditor() { return <div className="p-8">InspectionEditor (stub)</div>; }
src/pages/InspectionReview.tsx:
export default function InspectionReview() { return <div className="p-8">InspectionReview (stub)</div>; }
- Step 4: Run dev, verify navigation
npm run dev
Open http://localhost:5173 — should redirect to /login. Open /inspections/123 — also redirects to login.
- Step 5: Commit
git add mechanic-pwa/frontend/src
git commit -m "feat(mechanic-pwa): routing skeleton with stub pages"
Phase H — Frontend Pages (3-5 days)
Task H1: Login page
Files:
-
Modify:
mechanic-pwa/frontend/src/pages/Login.tsx -
Modify:
mechanic-pwa/frontend/src/api/auth.ts(create) -
Step 1: Write API function
src/api/auth.ts:
import { api } from "./client";
export interface LoginInput { email: string; pin: string; }
export interface LoginOutput { token: string; }
export async function login(input: LoginInput): Promise<LoginOutput> {
return api.post("auth/login", { json: input }).json<LoginOutput>();
}
Adjust to existing TaxiDashboard auth endpoint (may already exist under a different path; if so — import that instead of duplicating).
- Step 2: Implement Login page
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { login } from "@/api/auth";
import { useAuth } from "@/store/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function Login() {
const navigate = useNavigate();
const setToken = useAuth((s) => s.setToken);
const [email, setEmail] = useState("");
const [pin, setPin] = useState("");
const m = useMutation({
mutationFn: login,
onSuccess: (data) => {
setToken(data.token);
toast.success("Вход выполнен");
navigate("/", { replace: true });
},
onError: () => toast.error("Не удалось войти"),
});
return (
<div className="min-h-screen flex items-center justify-center bg-[#f5f1ea]">
<div className="w-full max-w-sm bg-white rounded-2xl shadow-sm p-6 space-y-4">
<h1 className="text-2xl font-semibold">Premium Механик</h1>
<form
onSubmit={(e) => {
e.preventDefault();
m.mutate({ email, pin });
}}
className="space-y-3"
>
<div className="space-y-1">
<Label htmlFor="email">Почта</Label>
<Input id="email" type="email" value={email}
onChange={(e) => setEmail(e.target.value)} required autoFocus />
</div>
<div className="space-y-1">
<Label htmlFor="pin">PIN</Label>
<Input id="pin" type="password" inputMode="numeric"
value={pin} onChange={(e) => setPin(e.target.value)} required />
</div>
<Button type="submit" disabled={m.isPending} className="w-full">
{m.isPending ? "Входим…" : "Войти"}
</Button>
</form>
</div>
</div>
);
}
- Step 3: Manual test
npm run dev → визит /login → ввод тестовых креденшалов → toast + redirect на /.
- Step 4: Commit
git add mechanic-pwa/frontend/src/pages/Login.tsx mechanic-pwa/frontend/src/api/auth.ts
git commit -m "feat(mechanic-pwa): Login page"
Task H2: Home page — vehicle search
Files:
-
Modify:
mechanic-pwa/frontend/src/pages/Home.tsx -
Create:
mechanic-pwa/frontend/src/api/vehicles.ts -
Step 1: Write API module
src/api/vehicles.ts:
import { api } from "./client";
export interface VehicleSummary {
id: number;
license_plate: string;
vin?: string;
make?: string;
model?: string;
year?: number;
last_inspection_at?: string;
}
export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
const params = q ? { q } : {};
const res = await api.get("vehicles", { searchParams: params }).json<{ items: VehicleSummary[] }>();
return res.items;
}
- Step 2: Implement Home page
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { listVehicles } from "@/api/vehicles";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
export default function Home() {
const [q, setQ] = useState("");
const navigate = useNavigate();
const { data, isLoading } = useQuery({
queryKey: ["vehicles", q],
queryFn: () => listVehicles(q || undefined),
});
return (
<div className="min-h-screen bg-[#f5f1ea] p-4">
<h1 className="text-2xl font-semibold mb-4">Машины</h1>
<Input
placeholder="Поиск по номеру или VIN"
value={q}
onChange={(e) => setQ(e.target.value)}
className="mb-4"
/>
{isLoading ? (
<div>Загружаю…</div>
) : (
<div className="space-y-2">
{(data ?? []).map((v) => (
<Card
key={v.id}
className="p-4 cursor-pointer hover:shadow"
onClick={() => navigate(`/vehicles/${v.id}`)}
>
<div className="font-semibold text-lg">{v.license_plate}</div>
<div className="text-sm text-gray-500">
{[v.make, v.model, v.year].filter(Boolean).join(" ")}
</div>
{v.last_inspection_at && (
<div className="text-xs text-gray-400 mt-1">
Последний осмотр: {new Date(v.last_inspection_at).toLocaleDateString("ru-RU")}
</div>
)}
</Card>
))}
</div>
)}
</div>
);
}
- Step 3: Commit
git add mechanic-pwa/frontend/src
git commit -m "feat(mechanic-pwa): Home page with vehicle search"
Task H3: VehicleCard page
Files:
-
Modify:
mechanic-pwa/frontend/src/pages/VehicleCard.tsx -
Modify:
mechanic-pwa/frontend/src/api/vehicles.ts -
Create:
mechanic-pwa/frontend/src/api/inspections.ts -
Step 1: Append to
vehicles.ts
export interface VehicleDetail {
id: number; license_plate: string; vin?: string;
make?: string; model?: string; year?: number;
recent_inspections: {
id: number; type: string; status: string;
started_at: string; finished_at?: string;
}[];
}
export async function getVehicle(id: number): Promise<VehicleDetail> {
return api.get(`vehicles/${id}`).json<VehicleDetail>();
}
- Step 2: Create
inspections.ts
import { api } from "./client";
export type InspectionType = "handover" | "return" | "periodic" | "ad-hoc";
export interface InspectionSummary {
id: number; type: InspectionType; status: string;
started_at: string; finished_at?: string;
}
export async function createInspection(input: {
vehicle_id: number; type: InspectionType; driver_id?: number;
}): Promise<{ id: number }> {
return api.post("inspections", { json: input }).json();
}
- Step 3: Implement VehicleCard page
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { getVehicle } from "@/api/vehicles";
import { createInspection, type InspectionType } from "@/api/inspections";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
export default function VehicleCard() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const vehicleId = Number(id);
const { data: v, isLoading } = useQuery({
queryKey: ["vehicle", vehicleId],
queryFn: () => getVehicle(vehicleId),
});
const startInspection = useMutation({
mutationFn: (type: InspectionType) =>
createInspection({ vehicle_id: vehicleId, type }),
onSuccess: ({ id: newId }) => {
navigate(`/inspections/${newId}/edit`);
},
onError: () => toast.error("Не удалось создать осмотр"),
});
if (isLoading || !v) return <div className="p-8">Загружаю…</div>;
return (
<div className="min-h-screen bg-[#f5f1ea] p-4 space-y-4">
<button onClick={() => navigate("/")} className="text-sm text-gray-500">
← К списку
</button>
<Card className="p-4">
<div className="text-xl font-semibold">{v.license_plate}</div>
<div className="text-sm text-gray-600">
{[v.make, v.model, v.year].filter(Boolean).join(" ")}
</div>
{v.vin && <div className="text-xs text-gray-400 mt-1">VIN: {v.vin}</div>}
</Card>
<div className="grid grid-cols-2 gap-2">
<Button onClick={() => startInspection.mutate("handover")}>Передача</Button>
<Button onClick={() => startInspection.mutate("return")}>Приёмка</Button>
<Button onClick={() => startInspection.mutate("periodic")} variant="outline">Плановый</Button>
<Button onClick={() => startInspection.mutate("ad-hoc")} variant="outline">Свободный</Button>
</div>
<h2 className="text-lg font-semibold mt-6">Прошлые осмотры</h2>
<div className="space-y-2">
{v.recent_inspections.length === 0 && (
<div className="text-sm text-gray-500">Осмотров ещё не было.</div>
)}
{v.recent_inspections.map((i) => (
<Card key={i.id} className="p-3 cursor-pointer"
onClick={() => navigate(`/inspections/${i.id}`)}>
<div className="text-sm">
<span className="font-medium">{i.type}</span>
{" — "}
{new Date(i.started_at).toLocaleString("ru-RU")}
</div>
<div className="text-xs text-gray-500">{i.status}</div>
</Card>
))}
</div>
</div>
);
}
- Step 4: Commit
git add mechanic-pwa/frontend/src
git commit -m "feat(mechanic-pwa): VehicleCard page with start-inspection buttons"
Phase I — Frontend Inspection Flow (4-6 days)
Task I1: CameraCapture component
Files:
-
Create:
mechanic-pwa/frontend/src/components/camera/CameraCapture.tsx -
Step 1: Implement (use
<input type="file" capture>for max compat)
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
interface Props {
onCapture: (file: File) => void;
buttonLabel?: string;
}
export function CameraCapture({ onCapture, buttonLabel = "Сделать фото" }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
return (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
setPreview(URL.createObjectURL(file));
onCapture(file);
}}
/>
{preview ? (
<div>
<img src={preview} alt="" className="w-full rounded-lg" />
<Button variant="outline" className="mt-2 w-full"
onClick={() => inputRef.current?.click()}>
Переснять
</Button>
</div>
) : (
<Button onClick={() => inputRef.current?.click()} className="w-full">
{buttonLabel}
</Button>
)}
</div>
);
}
- Step 2: Commit
git add mechanic-pwa/frontend/src/components/camera
git commit -m "feat(mechanic-pwa): CameraCapture component"
Task I2: Photo upload helper (presigned flow)
Files:
-
Create:
mechanic-pwa/frontend/src/api/photos.ts -
Step 1: Implement
import { api } from "./client";
export interface PresignedUpload {
photo_id: number;
presigned_url: string;
fields: Record<string, string>;
storage_key: string;
expires_at: string;
}
export async function requestUploadUrl(
inspectionId: number,
args: { side: string; slot_index: number; content_type: string; size: number },
): Promise<PresignedUpload> {
return api
.post(`inspections/${inspectionId}/photos/upload-url`, { json: args })
.json<PresignedUpload>();
}
export async function uploadToS3(p: PresignedUpload, file: File): Promise<void> {
const fd = new FormData();
for (const [k, v] of Object.entries(p.fields)) fd.append(k, v);
fd.append("file", file);
const r = await fetch(p.presigned_url, { method: "POST", body: fd });
if (!r.ok) throw new Error(`S3 upload failed: ${r.status}`);
}
export async function confirmPhoto(
photoId: number,
meta: { width?: number; height?: number; actual_size?: number },
): Promise<{ id: number; status: string }> {
return api.post(`photos/${photoId}/confirm`, { json: meta }).json();
}
export async function uploadPhoto(
inspectionId: number,
side: string,
slotIndex: number,
file: File,
): Promise<number> {
const presign = await requestUploadUrl(inspectionId, {
side, slot_index: slotIndex,
content_type: file.type || "image/jpeg",
size: file.size,
});
await uploadToS3(presign, file);
// Get image dimensions
const dim = await new Promise<{ w: number; h: number }>((resolve) => {
const img = new Image();
img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
img.src = URL.createObjectURL(file);
});
await confirmPhoto(presign.photo_id, {
width: dim.w, height: dim.h, actual_size: file.size,
});
return presign.photo_id;
}
- Step 2: Commit
git add mechanic-pwa/frontend/src/api/photos.ts
git commit -m "feat(mechanic-pwa): photo upload helper (presigned POST)"
Task I3: PhotoSlot component
Files:
-
Create:
mechanic-pwa/frontend/src/components/camera/PhotoSlot.tsx -
Step 1: Implement
import { useState } from "react";
import { CameraCapture } from "./CameraCapture";
import { uploadPhoto } from "@/api/photos";
import { toast } from "sonner";
interface Props {
inspectionId: number;
side: string;
slotIndex: number;
label: string;
initialPhotoId?: number;
onUploaded?: (photoId: number) => void;
}
export function PhotoSlot({ inspectionId, side, slotIndex, label, initialPhotoId, onUploaded }: Props) {
const [photoId, setPhotoId] = useState<number | undefined>(initialPhotoId);
const [uploading, setUploading] = useState(false);
async function handleCapture(file: File) {
setUploading(true);
try {
const id = await uploadPhoto(inspectionId, side, slotIndex, file);
setPhotoId(id);
onUploaded?.(id);
toast.success(`${label} загружено`);
} catch (e) {
toast.error("Ошибка загрузки");
} finally {
setUploading(false);
}
}
return (
<div className="border rounded-lg p-3 bg-white">
<div className="text-sm font-medium mb-2">{label}</div>
{photoId && (
<img
src={`/api/v1/mechanic/photos/${photoId}?variant=thumb`}
alt={label}
className="w-full rounded mb-2"
/>
)}
<CameraCapture
onCapture={handleCapture}
buttonLabel={photoId ? "Заменить" : "Снять"}
/>
{uploading && <div className="text-xs text-gray-500 mt-1">Загружаю…</div>}
</div>
);
}
- Step 2: Commit
git add mechanic-pwa/frontend/src/components/camera
git commit -m "feat(mechanic-pwa): PhotoSlot component"
Task I4: Vehicle SVG scheme + HotSpot
Files:
-
Create:
mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg -
Create:
mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx -
Create:
mechanic-pwa/frontend/src/components/vehicle-scheme/HotSpot.tsx -
Step 1: Build minimalist top-view SVG of sedan
Create sedan-top.svg (simplified vector outline; can be replaced later with cleaner artwork):
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 400">
<!-- Body outline -->
<rect x="40" y="20" width="120" height="360" rx="40" ry="40"
fill="none" stroke="#444" stroke-width="2"/>
<!-- Windshield -->
<rect x="55" y="60" width="90" height="60" rx="10" fill="#dde6ec"/>
<!-- Rear window -->
<rect x="55" y="280" width="90" height="50" rx="10" fill="#dde6ec"/>
<!-- Roof -->
<rect x="55" y="130" width="90" height="140" fill="#f3eee4" stroke="#888"/>
<!-- Zone labels (id-marked hotspot rectangles, transparent) -->
<rect id="zone-front" x="40" y="20" width="120" height="40" fill="transparent"/>
<rect id="zone-rear" x="40" y="340" width="120" height="40" fill="transparent"/>
<rect id="zone-left" x="40" y="60" width="20" height="280" fill="transparent"/>
<rect id="zone-right" x="140" y="60" width="20" height="280" fill="transparent"/>
<rect id="zone-hood" x="55" y="20" width="90" height="40" fill="transparent"/>
<rect id="zone-trunk" x="55" y="340" width="90" height="40" fill="transparent"/>
<rect id="zone-roof" x="55" y="130" width="90" height="140" fill="transparent"/>
</svg>
- Step 2: Implement VehicleScheme component
import { useState } from "react";
import sedanSrc from "./schemes/sedan-top.svg?raw";
interface MarkerDot { x: number; y: number; severity: string; }
interface Props {
markers?: MarkerDot[];
onZoneClick?: (zoneId: string) => void;
}
const ZONES = [
"zone-front", "zone-rear", "zone-left", "zone-right",
"zone-hood", "zone-trunk", "zone-roof",
];
export function VehicleScheme({ markers = [], onZoneClick }: Props) {
const [hoverZone, setHoverZone] = useState<string | null>(null);
return (
<div className="relative w-full max-w-xs mx-auto">
<div
className="vehicle-scheme"
dangerouslySetInnerHTML={{ __html: sedanSrc }}
onClick={(e) => {
const target = (e.target as SVGElement);
const id = target.getAttribute("id");
if (id && ZONES.includes(id)) onZoneClick?.(id);
}}
onMouseMove={(e) => {
const target = (e.target as SVGElement);
const id = target.getAttribute("id");
setHoverZone(id && ZONES.includes(id) ? id : null);
}}
/>
{/* Render markers as red dots on top of SVG (normalized coords) */}
{markers.map((m, i) => (
<div
key={i}
className="absolute w-3 h-3 rounded-full bg-red-500/80 border border-white"
style={{ left: `${m.x * 100}%`, top: `${m.y * 100}%`, transform: "translate(-50%, -50%)" }}
/>
))}
{hoverZone && (
<div className="text-xs text-center mt-1 text-gray-600">
{humanize(hoverZone)}
</div>
)}
</div>
);
}
function humanize(zoneId: string): string {
const map: Record<string, string> = {
"zone-front": "Передний бампер",
"zone-rear": "Задний бампер",
"zone-left": "Левый бок",
"zone-right": "Правый бок",
"zone-hood": "Капот",
"zone-trunk": "Багажник",
"zone-roof": "Крыша",
};
return map[zoneId] || zoneId;
}
- Step 3: Commit
git add mechanic-pwa/frontend/src/components/vehicle-scheme
git commit -m "feat(mechanic-pwa): VehicleScheme SVG component"
Task I5: InspectionEditor page
Files:
-
Modify:
mechanic-pwa/frontend/src/pages/InspectionEditor.tsx -
Append to:
mechanic-pwa/frontend/src/api/inspections.ts -
Step 1: API extensions
Append to inspections.ts:
export interface MarkerSummary {
id: number; photo_id?: number; side?: string;
x?: number; y?: number;
damage_type?: string; severity?: string; description?: string;
carried_over_from_id?: number; resolved: boolean;
}
export interface PhotoSummary {
id: number; side: string; slot_index: number;
storage_key: string; thumb_key?: string;
width?: number; height?: number; status: string;
taken_at: string;
}
export interface InspectionDetail {
id: number; vehicle_id: number; type: string;
status: string; started_at: string; finished_at?: string;
notes?: string; prev_inspection_id?: number;
photos: PhotoSummary[];
markers: MarkerSummary[];
}
export async function getInspection(id: number): Promise<InspectionDetail> {
return api.get(`inspections/${id}`).json<InspectionDetail>();
}
export async function patchInspection(id: number, body: {
status?: string; notes?: string;
}): Promise<InspectionDetail> {
return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>();
}
export async function createMarker(inspectionId: number, body: {
photo_id?: number; side?: string;
x?: number; y?: number;
damage_type?: string; severity?: string; description?: string;
}): Promise<MarkerSummary> {
return api.post(`inspections/${inspectionId}/markers`, { json: body })
.json<MarkerSummary>();
}
- Step 2: Implement InspectionEditor
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { getInspection, patchInspection, createMarker } from "@/api/inspections";
import { PhotoSlot } from "@/components/camera/PhotoSlot";
import { VehicleScheme } from "@/components/vehicle-scheme/VehicleScheme";
import { Button } from "@/components/ui/button";
const SLOTS: { side: string; label: string }[] = [
{ side: "front", label: "Перед" },
{ side: "rear", label: "Зад" },
{ side: "left", label: "Левый бок" },
{ side: "right", label: "Правый бок" },
{ side: "vin", label: "VIN" },
{ side: "odometer", label: "Одометр" },
{ side: "interior", label: "Салон" },
{ side: "free", label: "Свободное" },
];
export default function InspectionEditor() {
const { id } = useParams<{ id: string }>();
const insId = Number(id);
const navigate = useNavigate();
const qc = useQueryClient();
const { data: ins, isLoading } = useQuery({
queryKey: ["inspection", insId],
queryFn: () => getInspection(insId),
});
const finalize = useMutation({
mutationFn: () => patchInspection(insId, { status: "completed" }),
onSuccess: () => {
toast.success("Осмотр завершён");
navigate(`/inspections/${insId}`);
},
});
const addZoneMarker = useMutation({
mutationFn: (zone: string) =>
createMarker(insId, { side: zone, description: "Метка со схемы", damage_type: "scratch", severity: "cosmetic" }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["inspection", insId] });
toast.success("Метка добавлена");
},
});
if (isLoading || !ins) return <div className="p-8">Загружаю…</div>;
const photosBySide = new Map<string, typeof ins.photos[0]>();
for (const p of ins.photos) photosBySide.set(`${p.side}-${p.slot_index}`, p);
const dotsForScheme = ins.markers
.filter((m) => m.side?.startsWith("zone-") && m.x != null && m.y != null)
.map((m) => ({ x: Number(m.x), y: Number(m.y), severity: m.severity || "minor" }));
return (
<div className="min-h-screen bg-[#f5f1ea] p-4 space-y-4">
<button onClick={() => navigate(-1)} className="text-sm text-gray-500">← Назад</button>
<h1 className="text-xl font-semibold">Осмотр #{ins.id} ({ins.type})</h1>
<section>
<h2 className="text-lg font-medium mb-2">Схема — кликни на зону, чтобы поставить метку</h2>
<VehicleScheme
markers={dotsForScheme}
onZoneClick={(zone) => addZoneMarker.mutate(zone)}
/>
</section>
<section>
<h2 className="text-lg font-medium mb-2">Фото</h2>
<div className="grid grid-cols-2 gap-2">
{SLOTS.map((s) => (
<PhotoSlot
key={`${s.side}-0`}
inspectionId={insId}
side={s.side}
slotIndex={0}
label={s.label}
initialPhotoId={photosBySide.get(`${s.side}-0`)?.id}
onUploaded={() => qc.invalidateQueries({ queryKey: ["inspection", insId] })}
/>
))}
</div>
</section>
{ins.markers.length > 0 && (
<section>
<h2 className="text-lg font-medium mb-2">Метки ({ins.markers.length})</h2>
<ul className="space-y-1 text-sm">
{ins.markers.map((m) => (
<li key={m.id} className="bg-white p-2 rounded">
{m.side} — {m.damage_type || "n/a"} — {m.severity || "n/a"}
{m.carried_over_from_id && <span className="text-xs text-gray-500"> (из прошлого)</span>}
</li>
))}
</ul>
</section>
)}
<Button className="w-full" onClick={() => finalize.mutate()}>
Завершить осмотр
</Button>
</div>
);
}
- Step 3: Manual smoke test
npm run dev → пройти flow login → home → vehicle → start inspection → попасть в editor → попробовать тапать на зоны схемы (markers создаются), снимать фото (uploads пройдут на dev S3 если CORS настроен).
- Step 4: Commit
git add mechanic-pwa/frontend/src
git commit -m "feat(mechanic-pwa): InspectionEditor with scheme + photo slots + zone markers"
Task I6: InspectionReview page (read-only обзор завершённого осмотра)
Files:
-
Modify:
mechanic-pwa/frontend/src/pages/InspectionReview.tsx -
Step 1: Implement
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getInspection } from "@/api/inspections";
import { Card } from "@/components/ui/card";
export default function InspectionReview() {
const { id } = useParams<{ id: string }>();
const insId = Number(id);
const navigate = useNavigate();
const { data: ins, isLoading } = useQuery({
queryKey: ["inspection", insId],
queryFn: () => getInspection(insId),
});
if (isLoading || !ins) return <div className="p-8">Загружаю…</div>;
return (
<div className="min-h-screen bg-[#f5f1ea] p-4 space-y-4">
<button onClick={() => navigate(-1)} className="text-sm text-gray-500">← Назад</button>
<h1 className="text-xl font-semibold">Осмотр #{ins.id}</h1>
<Card className="p-4 text-sm space-y-1">
<div><b>Тип:</b> {ins.type}</div>
<div><b>Статус:</b> {ins.status}</div>
<div><b>Начат:</b> {new Date(ins.started_at).toLocaleString("ru-RU")}</div>
{ins.finished_at && <div><b>Завершён:</b> {new Date(ins.finished_at).toLocaleString("ru-RU")}</div>}
{ins.notes && <div><b>Заметки:</b> {ins.notes}</div>}
</Card>
<h2 className="text-lg font-medium">Фото ({ins.photos.length})</h2>
<div className="grid grid-cols-2 gap-2">
{ins.photos.map((p) => (
<a
key={p.id}
href={`/api/v1/mechanic/photos/${p.id}`}
target="_blank" rel="noreferrer"
>
<img
src={`/api/v1/mechanic/photos/${p.id}?variant=thumb`}
alt={p.side}
className="w-full rounded"
/>
<div className="text-xs text-gray-500">{p.side}</div>
</a>
))}
</div>
<h2 className="text-lg font-medium">Метки ({ins.markers.length})</h2>
<ul className="space-y-1 text-sm">
{ins.markers.map((m) => (
<li key={m.id} className="bg-white p-2 rounded">
{m.side} — {m.damage_type || "—"} — {m.severity || "—"}
{m.description && <div className="text-xs text-gray-500 mt-1">{m.description}</div>}
{m.resolved && <span className="text-xs text-green-600 ml-2">✓ устранено</span>}
{m.carried_over_from_id && <span className="text-xs text-gray-400 ml-2">(перенесено)</span>}
</li>
))}
</ul>
</div>
);
}
- Step 2: Commit
git add mechanic-pwa/frontend/src/pages/InspectionReview.tsx
git commit -m "feat(mechanic-pwa): InspectionReview page"
Phase J — Integration & Deploy (1-2 days)
Task J1: End-to-end manual test in dev
- Step 1: Start backend with mechanic module
cd /opt/sites/taxi-dashboard/backend # or wherever
uvicorn app.main:app --reload --port 8000
- Step 2: Start frontend dev
cd c:/NewProject/PremiumDriverApp/mechanic-pwa/frontend
VITE_API_BASE=http://localhost:8000/api/v1/mechanic npm run dev
- Step 3: Full flow walkthrough
In browser, http://localhost:5173:
- Login with a mechanic-role test account
- Search for a vehicle (use any from
vehiclestable) - Open vehicle card → "Передача"
- In editor: click 2 zones on the scheme — markers appear; snap 2 photos
- Click "Завершить осмотр"
- Review page shows photos + markers
Expected: full flow without errors. If photo upload fails — check CORS on MinIO (Task A3) and that the access keys in minio.env are correct.
-
Step 4: Take screenshots, save to
mechanic-pwa/docs/ -
Step 5: Note any issues found, file as follow-up tasks
Task J2: Backend production deploy
- Step 1: Pull code on VDS
ssh root@100.64.0.12
cd /opt/sites/taxi-dashboard
git pull
- Step 2: Install new deps, run migration
source .venv/bin/activate
pip install -r requirements.txt # if aiobotocore/Pillow were added there
alembic upgrade head
- Step 3: Restart backend service
systemctl restart taxi-dashboard-backend.service # adjust unit name
journalctl -u taxi-dashboard-backend -f --since "1 min ago"
Expected: started without errors, healthz responds.
curl https://crm.pptaxi.ru/api/v1/mechanic/healthz
Expected: {"status":"ok"}.
- Step 4: Commit deployment notes if needed
Task J3: Frontend production deploy
- Step 1: Build
cd c:/NewProject/PremiumDriverApp/mechanic-pwa/frontend
npm run build
Expected: dist/ directory with optimized assets.
- Step 2: Rsync to VDS
rsync -avz --delete dist/ root@100.64.0.12:/opt/sites/mechanic-pwa/dist/
- Step 3: Wire Caddy fragment
On VDS:
cp /opt/sites/mechanic-pwa/repo/mechanic-pwa/ops/Caddyfile.fragment /etc/caddy/sites-enabled/mechanic.pptaxi.ru
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
(Adjust path of fragment based on actual repo location.)
- Step 4: Smoke test via curl
curl -I https://mechanic.pptaxi.ru
curl https://mechanic.pptaxi.ru/manifest.webmanifest
Expected: 200 OK; manifest JSON returns.
- Step 5: Open in mobile browser
On a phone, go to https://mechanic.pptaxi.ru. Verify:
-
Page loads
-
"Add to Home Screen" appears in browser menu
-
After adding, opens full-screen as PWA
-
Step 6: Commit
git add mechanic-pwa
git commit -m "deploy(mechanic): first production deploy of MVP"
Task J4: Production smoke test
- Step 1: Run a real inspection on production
Use a test mechanic account; create an inspection on a test vehicle; snap a real photo (small); add markers; finalize.
- Step 2: Verify photo in MinIO
mc ls local/pp-inspections/inspections/<new-id>/original/
Expected: at least one .jpg file present.
- Step 3: Verify thumbnail (after celery task runs)
Wait ~10 seconds, then:
mc ls local/pp-inspections/inspections/<new-id>/thumb/
Expected: matching .jpg thumbnail.
- Step 4: Verify DB rows
psql $DATABASE_URL -c "SELECT id, vehicle_id, type, status FROM inspections ORDER BY id DESC LIMIT 5;"
psql $DATABASE_URL -c "SELECT id, side, status FROM inspection_photos WHERE inspection_id=<new-id>;"
psql $DATABASE_URL -c "SELECT * FROM damage_markers WHERE inspection_id=<new-id>;"
- Step 5: Sign off
If all of the above pass — MVP is live. File any UX issues found as follow-up GitHub issues.
Acceptance Criteria for Phase 1 MVP
- Mechanic can log in on
mechanic.pptaxi.rufrom a mobile browser - Can find a vehicle by license plate
- Can open a vehicle card and see recent inspections
- Can start a handover/return/periodic/ad-hoc inspection
- On
return, previously-recorded markers are auto-carried over with "carried_over_from_id" links - Can take ≥4 photos in inspection slots, uploaded directly to MinIO via pre-signed POST
- Can click zones on the SVG scheme to add point markers
- Can finalize inspection (status=completed, finished_at filled)
- Can view a read-only review of a finalized inspection with all photos + markers
- PWA is installable to home screen on both Android and iOS
What's NOT in Phase 1 (deferred to Phase 2+)
- Polygon/freehand annotation editor over photos (Konva-based)
- Offline-first mode with IndexedDB queue and SW background sync
- Capacitor-wrapped native APK/IPA
- Push notifications
- VendorBridge data import from
api.ttcontrol.naughtysoft.ru - Admin dashboard with statistics
- QR-сканер штрих-кодов на машинах
Self-Review Notes
- Spec coverage: §1-12 from
2026-05-16-premium-mechanic-design.md— все ключевые требования покрыты: модель данных (Phase A4 / Phase B2), API endpoints (Phases C-F), UI flow (Phase G-I), MinIO storage (Phase E), deployment (Phase J). Полигональный editor отложен в Phase 2 как явно согласовано. - No placeholders: каждый
Stepимеет либо bash-команду, либо полный код. Где ссылки на «adjust import to existing path» (paths модели User, Vehicle, Base, async_session_factory) — Task A1 даёт engineer'у инструкцию выяснить точные пути перед началом implementation. - Type consistency: Pydantic схемы (
InspectionType,PhotoSide,DamageType,Severity) — последовательно использованы между backend и frontend. SQLAlchemy column names в migration (Task A4) совпадают с column declarations в models.py (Task B2). API response shapes в openapi (router) совпадают с TypeScript interfaces во frontend api/*.ts.