3586 lines
106 KiB
Markdown
3586 lines
106 KiB
Markdown
# 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` — register `mechanic_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 for `mechanic.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-mvp` off `main` (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:
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```caddy
|
||
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:
|
||
|
||
```bash
|
||
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)**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
# 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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()` and `downgrade()`**
|
||
|
||
Replace migration content with:
|
||
|
||
```python
|
||
"""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**
|
||
|
||
```bash
|
||
alembic upgrade head
|
||
```
|
||
|
||
Expected: `Running upgrade <prev> -> <new>, mechanic_init`.
|
||
|
||
- [ ] **Step 4: Verify schema in psql**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
alembic downgrade -1
|
||
alembic upgrade head
|
||
```
|
||
|
||
Expected: both succeed without errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
"""Premium Mechanic — PWA backend module for vehicle inspections."""
|
||
|
||
from .router import router
|
||
|
||
__all__ = ["router"]
|
||
```
|
||
|
||
`backend/app/mechanic/router.py`:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
pytest backend/tests/test_mechanic/test_models.py -v
|
||
```
|
||
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
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)**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
"""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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
pytest backend/tests/test_mechanic/test_me.py -v
|
||
```
|
||
|
||
Expected: 2 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
pytest backend/tests/test_mechanic/test_vehicles.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Implement service**
|
||
|
||
Create `backend/app/mechanic/service.py`:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
@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**
|
||
|
||
```bash
|
||
pytest backend/tests/test_mechanic/test_vehicles.py::test_vehicle_detail -v
|
||
```
|
||
|
||
- [ ] **Step 3: Add service method**
|
||
|
||
Append to `service.py`:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
@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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
pytest backend/tests/test_mechanic/test_inspections.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Implement service**
|
||
|
||
Append to `service.py`:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
@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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
@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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
@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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
@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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
@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`:
|
||
|
||
```python
|
||
@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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
npx tailwindcss init -p
|
||
```
|
||
|
||
Edit `tailwind.config.js`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Visit `http://localhost:5173`. Expected: Vite welcome page with Inter font.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
cd mechanic-pwa/frontend
|
||
npx shadcn@latest init -d
|
||
```
|
||
|
||
Use defaults: style=default, color=slate (cream-friendly).
|
||
|
||
- [ ] **Step 2: Add starter components**
|
||
|
||
```bash
|
||
npx shadcn@latest add button card input label sonner
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```json
|
||
{
|
||
"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`:
|
||
|
||
```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>`:
|
||
|
||
```html
|
||
<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:
|
||
|
||
```bash
|
||
# 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**
|
||
|
||
```bash
|
||
npm run build
|
||
npm run preview
|
||
```
|
||
|
||
Open in browser → DevTools → Application → Manifest → should show `Premium Механик` with both icon sizes.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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`**
|
||
|
||
```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.tsx` with route skeleton**
|
||
|
||
```tsx
|
||
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`:
|
||
```tsx
|
||
export default function Login() { return <div className="p-8">Login (stub)</div>; }
|
||
```
|
||
|
||
`src/pages/Home.tsx`:
|
||
```tsx
|
||
export default function Home() { return <div className="p-8">Home (stub)</div>; }
|
||
```
|
||
|
||
`src/pages/VehicleCard.tsx`:
|
||
```tsx
|
||
export default function VehicleCard() { return <div className="p-8">VehicleCard (stub)</div>; }
|
||
```
|
||
|
||
`src/pages/InspectionEditor.tsx`:
|
||
```tsx
|
||
export default function InspectionEditor() { return <div className="p-8">InspectionEditor (stub)</div>; }
|
||
```
|
||
|
||
`src/pages/InspectionReview.tsx`:
|
||
```tsx
|
||
export default function InspectionReview() { return <div className="p-8">InspectionReview (stub)</div>; }
|
||
```
|
||
|
||
- [ ] **Step 4: Run dev, verify navigation**
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Open `http://localhost:5173` — should redirect to `/login`. Open `/inspections/123` — also redirects to login.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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`**
|
||
|
||
```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`**
|
||
|
||
```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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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)**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```ts
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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
|
||
<?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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
cd /opt/sites/taxi-dashboard/backend # or wherever
|
||
uvicorn app.main:app --reload --port 8000
|
||
```
|
||
|
||
- [ ] **Step 2: Start frontend dev**
|
||
|
||
```bash
|
||
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:
|
||
|
||
1. Login with a mechanic-role test account
|
||
2. Search for a vehicle (use any from `vehicles` table)
|
||
3. Open vehicle card → "Передача"
|
||
4. In editor: click 2 zones on the scheme — markers appear; snap 2 photos
|
||
5. Click "Завершить осмотр"
|
||
6. 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**
|
||
|
||
```bash
|
||
ssh root@100.64.0.12
|
||
cd /opt/sites/taxi-dashboard
|
||
git pull
|
||
```
|
||
|
||
- [ ] **Step 2: Install new deps, run migration**
|
||
|
||
```bash
|
||
source .venv/bin/activate
|
||
pip install -r requirements.txt # if aiobotocore/Pillow were added there
|
||
alembic upgrade head
|
||
```
|
||
|
||
- [ ] **Step 3: Restart backend service**
|
||
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
cd c:/NewProject/PremiumDriverApp/mechanic-pwa/frontend
|
||
npm run build
|
||
```
|
||
|
||
Expected: `dist/` directory with optimized assets.
|
||
|
||
- [ ] **Step 2: Rsync to VDS**
|
||
|
||
```bash
|
||
rsync -avz --delete dist/ root@100.64.0.12:/opt/sites/mechanic-pwa/dist/
|
||
```
|
||
|
||
- [ ] **Step 3: Wire Caddy fragment**
|
||
|
||
On VDS:
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
mc ls local/pp-inspections/inspections/<new-id>/thumb/
|
||
```
|
||
|
||
Expected: matching `.jpg` thumbnail.
|
||
|
||
- [ ] **Step 4: Verify DB rows**
|
||
|
||
```bash
|
||
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.ru` from 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.
|