23 KiB
Premium Mechanic PWA — Implementation State
Last updated: 2026-05-17 (end of session 3 — Phase E COMPLETE)
What is done
| Phase | Task | Status | Artifact |
|---|---|---|---|
| 0 | Git infrastructure (Gitea + repos + feature branches) | ✅ DONE | repos exist, ssh keys configured |
| A | A1: Recon TaxiDashboard backend structure | ✅ DONE | docs/backend-layout-notes.md |
| A | A2: Caddy fragment for mechanic.pptaxi.ru |
✅ DONE | ops/Caddyfile.fragment |
| A | A3: MinIO CORS on pp-inspections bucket |
✅ DONE | ops/set-minio-cors.sh, applied, preflight 204 |
| A | A4: SQLAlchemy models (MechanicInspection, MechanicInspectionPhoto, MechanicDamageMarker) |
✅ DONE | commit be0553e on feat/mechanic-mvp |
| B | B1: Mechanic module skeleton + /healthz |
✅ DONE | commit d2113a0 — flat backend/app/api/mechanic.py with APIRouter(prefix="/v1/mechanic"), registered in app/main.py with prefix="/api" → final URL /api/v1/mechanic/healthz |
| B | B2: SQLAlchemy models registered + tests | ✅ DONE | already shipped in A4 (be0553e) — 38 metadata tests in backend/tests/test_mechanic_models.py, no extra B2 commit |
| B | B3: Pydantic schemas | ✅ DONE | commit 2739cc8 + fix 2fb978d — 14 schemas + 6 Literals in backend/app/api/mechanic_schemas.py, 15 behavioral tests in backend/tests/test_mechanic_schemas.py; fix added = None defaults to Optional[X] fields and tightened MarkerOut.damage_type/severity to DamageType/Severity Literals |
| B | B4: Auth + DB session dependencies | ✅ DONE | commit f0d7e83 — backend/app/api/mechanic_deps.py re-exports get_db+get_current_user, adds require_mechanic guard (admin OR member of any active dept_type='svc' department); 9 tests in backend/tests/test_mechanic_deps.py use fake-DB stubs (no real session needed) |
| C | C1: GET /me endpoint |
✅ DONE | commit a0bdcaa + signature fix 1e5b822 — appended to backend/app/api/mechanic.py; returns MeOut from require_mechanic user; permissions is ["admin", "mechanic:inspect"] for admins else ["mechanic:inspect"]. Test fixtures (client, override_mechanic, _fake_user) in backend/tests/test_mechanic_endpoints.py use FastAPI TestClient + app.dependency_overrides per Option A — no real DB / no async. 6 endpoint tests. |
| C | C2: GET /vehicles list with q+limit search |
✅ DONE | commit 7be8783 — new backend/app/api/mechanic_service.py with search_vehicles_with_last_inspection (single aggregated subquery + outerjoin to avoid N+1 for last completed inspection per vehicle); endpoint in mechanic.py; new VehicleListResponse schema; 8 tests (6 endpoint + 2 service-stmt) with _FakeSession.execute() returning canned rows. Search hits plate (CarV2.number) + VIN only. |
| C | C3: GET /vehicles/{id} detail + bound /vehicles limit |
✅ DONE | commit 9109383 + style fix 90b8c66 — new VehicleDetail(VehicleSummary) + InspectionSummary(_Base) schemas; service get_vehicle_by_id + recent_mechanic_inspections; endpoint raises 404 when missing, last_inspection_at derived from first completed inspection in the list; applied Query(20, ge=1, le=200) bounds to C2's /vehicles limit param. 7 new tests (5 detail + 2 bounds). Service is now FastAPI-free (404 raise lives in endpoint, matching automations.py convention). |
| D-pre | B3 schemas widened from vendor recon (a187a92) |
✅ DONE | After read-only recon of car_inspections (14k rows, 2yr): InspectionType extended 4→9 (added initial, seizure, equipment-change, pre-repair, post-repair); DamageType fully replaced 7→16 (removed rust/glass/paint which had 0 vendor occurrences; added real-world types from vendor distribution: damaged, chip, scuff, burn, bent, puncture, bulge, not-working, etc.). Severity unchanged (4 values, optional — vendor populates only 3%). 2 new positive-coverage tests. Stale comment in mechanic_damage_marker.py:54 references old DamageType values — fix in Phase F when touching that model. |
| D | D1: POST /inspections with inline carry-over markers |
✅ DONE | commits 39a6579 + correctness fix ee3d370 — service start_inspection + latest_completed_inspection (with NULLS LAST ordering); endpoint returns 201 with full InspectionDetail via re-query with selectinload(photos, markers). Carry-over: copies all unresolved markers from prev completed inspection (resolved=False filter — closed defects don't reopen), each new marker has carried_over_from_id=source.id, photo_id=None, resolved=False. _FakeSession extended with add/flush/commit/refresh write-path methods + rows_per_call queue for multi-SELECT service flows. 9 new tests (6 endpoint + 3 service). |
| D | D2: GET /inspections/{id} with photos+markers |
✅ DONE | commit 1776a81 — service get_inspection_by_id_with_relations (selectinload both relationships, scalar_one_or_none); endpoint raises 404 with "inspection not found" parallel to vehicle's 404. D1's inline re-query block refactored to call the same helper (DRY). 6 new tests (4 endpoint + 2 service introspecting stmt._with_options). |
| D | D3: PATCH /inspections/{id} status/notes/finished_at |
✅ DONE | commit aefe117 — service get_inspection_by_id (thin session.get wrapper) + patch_inspection (auto-sets finished_at=now(UTC) when status→completed AND finished_at not in payload; setattr loop applies all model_dump(exclude_unset=True) fields). Endpoint re-loads via get_inspection_by_id_with_relations for the response. 8 new tests (6 endpoint + 2 service). Endpoint and service both named patch_inspection — disambiguated by module path in code, by as svc_patch alias in tests. |
| D | Housekeeping: extract test fakes to mechanic_fakes.py |
✅ DONE | commit eedbf23 — pure refactor. Moved _FakeResult, _FakeSession, _fake_user, _fake_car, _fake_inspection, _fake_inspection_orm, _fake_marker_orm from test_mechanic_endpoints.py (was 899 lines) to new backend/tests/mechanic_fakes.py (207 lines). Test file dropped to 697 lines. Pytest fixtures (client, override_mechanic, override_db) stayed in the test file. Phase E tests will import from mechanic_fakes directly. |
| E | E1: MinIO async presigned PUT URL wrapper | ✅ DONE | commit fe38981 — added presigned_put_url(bucket, key, *, content_type, expires_sec=300) to app/services/storage.py; PresignedUploadResponse.fields defaults to {}; 3 storage tests. |
| E | E2: POST /inspections/{id}/photos/upload-url |
✅ DONE | commit 111661b — service create_pending_photo + _storage_key_for_photo helper in mechanic_service.py; endpoint in mechanic.py with 415-before-404 guard; UUID4 hex storage keys at mechanic/inspections/{id}/<uuid>.<ext>; 9 new tests (7 endpoint + 2 service). |
| E | E3: POST /photos/{id}/confirm |
✅ DONE | commit 724b209 — service get_photo_by_id + confirm_photo (None-safe dim update, status→ready); endpoint verifies storage.exists, 409 if missing, 404 covers both photo-missing and inspection-mismatch; 9 new tests (7 endpoint + 2 service). _fake_photo_orm factory added to mechanic_fakes.py. |
| E | E4: GET /photos/{id} + /thumb (302 redirect) |
✅ DONE | commit 8361ee1 — auth-guarded 302 to presigned MinIO GET URLs (1h expiry). Thumb endpoint falls back to storage_key when thumb_key is None so galleries degrade gracefully if E5 task hasn't run yet. 8 endpoint tests. |
| E | E5: async thumbnail generation | ✅ DONE | commit b37a068 — generate_and_store_thumbnail worker (download → Pillow resize to 480px → upload → DB update) with try/except at each step (failures logged, return None, never crashes event loop). Triggered via asyncio.create_task from E3 confirm endpoint. _thumb_key_for predictable naming (<key-base>-thumb.jpg). pillow_heif.register_heif_opener() wrapped in try/except for dev-env tolerance. 6 new tests (5 storage unit + 1 endpoint trigger). |
Branch state: feat/mechanic-mvp at b37a068. Full stack: A4 → B1 → B3 + fix → B4 → C1 + fix → C2 → C3 + fix → B3-vendor-align → D1 + fix → D2 → D3 → test-refactor → E1 → E2 → E3 → E4 → E5. All pushed to origin (Gitea). See git log for full SHAs.
Test count on branch: 144 mechanic tests pass (38 models + 17 schemas + 9 deps + 9 storage + 71 endpoints) in ~2.5s, no real DB needed. Pillow image-resize tests use real PIL in-memory (verify JPEG magic header + dimensions); thumbnail task end-to-end uses mocked I/O.
Live mechanic API surface (11 routes):
GET /api/v1/mechanic/healthzGET /api/v1/mechanic/meGET /api/v1/mechanic/vehicles?q=&limit=GET /api/v1/mechanic/vehicles/{vehicle_id}POST /api/v1/mechanic/inspections— create with carry-over for type=returnGET /api/v1/mechanic/inspections/{inspection_id}— full detail with photos+markersPATCH /api/v1/mechanic/inspections/{inspection_id}— partial update + auto finished_atPOST /api/v1/mechanic/inspections/{inspection_id}/photos/upload-url— create pending photo row + presigned PUT URL (5min expiry)POST /api/v1/mechanic/inspections/{inspection_id}/photos/{photo_id}/confirm— verify upload, mark ready, schedule thumbnail taskGET /api/v1/mechanic/photos/{photo_id}— auth-guarded 302 to presigned GET (1h)GET /api/v1/mechanic/photos/{photo_id}/thumb— same with thumb_key, falls back to original
Phase E architecture decisions (RESOLVED 2026-05-17)
| Decision | Choice | Rationale |
|---|---|---|
| Upload pattern | PUT (single presigned URL) not POST/FormData | Simpler PWA code: fetch(url, {method:'PUT', body:blob}). PresignedUploadResponse.fields defaults to {} for future POST migration. |
| Storage key naming | mechanic/inspections/{insp_id}/<uuid4hex>.<ext> |
Human-readable path + unguessable suffix. UUID4 avoids enumeration. |
| Allowed content types | image/{jpeg,png,heic,webp} |
Whitelist; 415 on others. |
| Confirm status semantics | status="ready" set immediately on confirm; thumb generated async |
Frontend doesn't need to poll. Galleries fall back to original via E4 endpoint. |
| Thumbnail backend | asyncio.create_task + Pillow (no Celery) |
Pillow + 15+ existing fire-and-forget call sites; no broker setup needed. Thumb resize is ~50ms for typical photo — acceptable on event loop. |
| Thumb size | 480px max-edge, JPEG q=85 | Matches services/download_wazzup_media.py precedent. |
| Auth model for read URLs | Auth-required on /photos/{id} endpoint; presigned URLs themselves bearer-free (1h TTL) |
Same as existing media_proxy.py convention. |
Vendor data reality (from recon 2026-05-17 — 14,013 inspections, 2 years)
| Metric | Value | Implication |
|---|---|---|
| Inspections with damage | 1,941 / 14,013 = 13.9% | Most inspections have empty marker list — UI optimize for that |
| Median markers per inspection | 2 | Inline carry-over copy is cheap |
| p95 markers | 10 | Confirms no async needed |
| Max markers | 34 | Worst case still ~50 inserts on commit |
| Median photos per inspection | 11 (all_photo_ids) |
Includes 9 sides + damage photos |
| Max photos per inspection | 30 | Set MinIO presigned batch size accordingly |
| Carry-over in vendor | None — denormalized snapshot | Our prev_inspection_id+carried_over_from_id FK approach is net improvement |
| Vendor inspection status field | None — write-once when completed | Our in_progress/completed/cancelled is greenfield, no conflicts |
Where things live
Repos (Gitea: http://100.64.0.9:3001/tremble7681)
| Repo | Local clone | Working branch |
|---|---|---|
mechanic-pwa |
c:\NewProject\PremiumDriverApp\ |
feat/mechanic-mvp |
taxi-dashboard |
c:\NewProject\taxi-dashboard\ |
feat/mechanic-mvp |
taxi-dashboard clone has two remotes: origin → Gitea, vds → SSH to /opt/sites/taxi-dashboard/ on 100.64.0.12 (for pulling back-and-forth with prod).
Reference paths (real, verified — see docs/backend-layout-notes.md)
Base,TimestampMixin→app.models.baseget_db→app.db(file:app/db/__init__.py)get_current_user→app.auth.depsUser→app.models.user- Auth flow: OAuth2 password + Bearer JWT (not PIN as plan originally said)
- DB init:
Base.metadata.create_allat startup (not Alembic) + inlineALTER TABLE ADD COLUMN IF NOT EXISTSinapp/main.pylifespan - Backend Docker container:
taxi-backend-green/taxi-backend-blue(blue/green) - MinIO container:
taxi-minio - Models registered in:
app/models/__init__.py(imports +__all__)
Naming clash decision
Existing app/api/inspections.py + model CarInspection (table car_inspections) hold vendor-imported read-only data from NaughtySoft. Our new tables use the mechanic_ prefix to coexist:
mechanic_inspections(modelMechanicInspection)mechanic_inspection_photos(modelMechanicInspectionPhoto)mechanic_damage_markers(modelMechanicDamageMarker)
Plan-vs-reality overrides applied in Phase B (Authoritative)
Three places where the original plan (docs/superpowers/plans/2026-05-16-premium-mechanic-phase1.md) was overridden during execution because Phase A1 recon showed the codebase doesn't match the plan's assumptions:
| Plan said | Actual implementation | Why |
|---|---|---|
backend/app/mechanic/router.py (subpackage) |
backend/app/api/mechanic.py (flat) |
TaxiDashboard uses flat app/api/*.py files; no subpackages |
backend/app/mechanic/schemas.py (subpackage) |
backend/app/api/mechanic_schemas.py (flat sibling) |
Same convention |
backend/app/mechanic/deps.py (subpackage) |
backend/app/api/mechanic_deps.py (flat sibling) |
Same convention |
Router prefix /api/v1/mechanic |
Router prefix /v1/mechanic, with app.include_router(..., prefix="/api") in main.py |
Matches existing convention (inspections.py → /v2/inspections + include with /api). Final URL is identical: /api/v1/mechanic/... |
require_mechanic checks user.dept.lower() in (...) |
Queries UserDepartment ⨝ Department WHERE dept_type='svc' AND is_active, admin shortcuts before DB hit |
User has no dept attribute; departments are M2M via user_departments. DEPT_TYPES=['ops','svc','adm'], svc = service-type (мастерская) |
When dispatching Phase C–F implementers, copy this override map into their context so they don't follow the stale subpackage / dept paths from the plan.
Phase C testing-infrastructure decision (RESOLVED — Option A in force)
Phase C runs against FastAPI TestClient (sync) + app.dependency_overrides[require_mechanic] and app.dependency_overrides[get_db] to inject fake user / fake session. Fake session is a SimpleNamespace-flavored _FakeSession class in tests/test_mechanic_endpoints.py that supports .execute(stmt) (returns _FakeResult seeded with rows) and .get(model, pk) (returns seeded object or None).
No conftest.py, no pytest-asyncio, no real DB. SQL correctness is deliberately deferred to Phase J e2e.
If Phase D/E/F needs a more complex query shape (e.g. .scalars().one() / .first()), extend _FakeResult.scalars() accordingly — currently it returns self and supports only .all(). See test_mechanic_endpoints.py:_FakeResult docstring.
Next task
Task F1 — Markers CRUD (Phase F starts; only 2 tasks)
(From docs/superpowers/plans/2026-05-16-premium-mechanic-phase1.md Phase F.)
Working dir: c:\NewProject\taxi-dashboard (branch feat/mechanic-mvp, HEAD b37a068)
Phase F adds the marker endpoints — finally exposes the carry-over data we've been carefully copying since D1. After F, backend Phase 1 MVP is feature-complete and Phase G begins frontend work.
Two endpoints:
POST /inspections/{inspection_id}/markers— create a new damage marker (request:MarkerCreatefrom B3)PATCH /markers/{marker_id}— update marker (request:MarkerPatchfrom B3)
The plan likely also wants DELETE /markers/{marker_id} — verify in the plan. There may also be a "resolve marker" endpoint that's just a PATCH with resolved=True.
Important context:
MechanicDamageMarkerhas a stale comment at line 54 (refs old DamageType values likerust/glass/paint) — fix in F1 while touching this model area.MarkerCreateandMarkerOutschemas already exist inmechanic_schemas.pyfrom B3.- The carry-over markers (
carried_over_from_id IS NOT NULL) deserve a thought: PATCH should be allowed to update them (mechanic confirms the existing damage is still there with current photo); DELETE should probably be NOT allowed on carry-overs (only on freshly-created markers) to preserve audit trail. Verify this in the plan.
Phase F implementer pattern: apply the same Phase D path overrides (flat app/api/, MechanicDamageMarker not DamageMarker, etc.). Reuse _FakeSession + _fake_marker_orm from mechanic_fakes.py.
Phase D architecture decisions (RESOLVED 2026-05-17)
-
Carry-over: inline в POST /inspections (atomic in the same transaction). Until carry-over copy completes, the new inspection isn't "ready" for the UI — async would create race conditions and complicate testing without a meaningful payoff for ~10-20 markers per copy.
-
_FakeSessionwrite-path extension. Add the following to the existing_FakeSessionintests/test_mechanic_endpoints.py:add(obj)— appends toself.addedlist; auto-assignsobj.idif not set, popping from_next_idsqueue if provided, else incrementing_next_id_counter(starts at 1000 to avoid collision with test-seeded ids in the 1-99 range)async flush()— no-op +self.flush_count += 1(ids already assigned inadd)async commit()— no-op +self.commit_count += 1async refresh(obj)— no-op +self.refresh_count += 1rows_per_call: Optional[list[Sequence]]kwarg — if provided, eachexecute()call pops one item from this queue and returns it as the result rows. This handles multi-query service flows (e.g.start_inspectiondoesselect(prev_inspection)thenselect(prev_markers)).- Existing
execute(stmt)andget(model, pk)keep their behavior. - All extensions backward-compatible with current 83 tests.
-
Test infrastructure: stay on Option A, do NOT add real DB. SQL specifics (selectinload, cascade, JSONB) will be validated in Phase J e2e. aiosqlite ≠ Postgres (JSONB mismatch), testcontainers is too heavy for 3 endpoints, two-paradigm test suite is more pain than gain. Extend the mock instead (see point 2).
Phase D path overrides (apply to D1, D2, D3)
Same flat-file pattern as Phase B-C; map plan's stale paths:
| Plan says | Use instead |
|---|---|
backend/app/mechanic/router.py |
backend/app/api/mechanic.py (append) |
backend/app/mechanic/service.py |
backend/app/api/mechanic_service.py (append) |
backend/app/mechanic/schemas.py |
backend/app/api/mechanic_schemas.py (already has all needed shapes from B3) |
backend/tests/test_mechanic/test_inspections.py |
backend/tests/test_mechanic_endpoints.py (append) |
Inspection model |
MechanicInspection (app/models/mechanic_inspection) |
DamageMarker model |
MechanicDamageMarker (app/models/mechanic_damage_marker) |
InspectionPhoto model |
MechanicInspectionPhoto (app/models/mechanic_inspection_photo) |
service.get_vehicle_or_404 (plan) |
service.get_vehicle_by_id + endpoint-level None-check + HTTPException(404) raise (C3 fix) |
from fastapi import HTTPException inline in service |
At endpoint level only; service stays FastAPI-free |
404 raise inside endpoint via inline from fastapi import HTTPException |
Use top-of-file import (already there from C3) |
Pydantic schemas (already shipped in B3):
InspectionCreate(vehicle_id, type, driver_id) — request body for POSTInspectionPatch(status, notes, finished_at) — request body for PATCHInspectionDetail(full response with photos + markers list) — response model
Phase B3 schemas use model_config = ConfigDict(from_attributes=True) via _Base, so SQLAlchemy ORM instances serialize automatically with response_model=InspectionDetail.
How to resume in a new session
In a new Claude Code chat in c:\NewProject\, paste this prompt:
Продолжаем проект Premium Mechanic PWA.
Прочитай PremiumDriverApp/docs/superpowers/STATE.md — там полное состояние и следующая задача.
Phase E полностью завершён (E1-E5, последний коммит b37a068, 144/144 тестов зелёных).
Backend MVP API готов: 11 routes (полный flow inspection + photos + thumbnails).
Следующая фаза — Phase F (markers CRUD, 2 задачи) из плана.
После F backend Phase 1 MVP feature-complete и начинается Phase G (frontend PWA scaffold).
Используй superpowers:subagent-driven-development.
That prompt is the single entry point for the next session — Claude will read STATE.md, ground itself, and dispatch the F1 implementer.
Remaining roadmap (Phase 1 MVP)
Phase B (4 tasks)✅ done — module skeleton, models, schemas, auth depsPhase C (3 tasks)✅ done — GET /me, GET /vehicles, GET /vehicles/{id}Phase D (3 tasks)✅ done — POST /inspections (+ carry-over), GET /inspections/{id}, PATCH /inspections/{id}Phase E (5 tasks)✅ done — presigned PUT URL, photo upload-url, confirm, GET /photos/{id}+thumb, async thumb generation- Phase F (2 tasks): markers create/update/delete ← NEXT
- Phase G (5 tasks): Vite + React + Tailwind + shadcn + PWA scaffold
- Phase H (3 tasks): Login + Home + VehicleCard pages
- Phase I (6 tasks): CameraCapture + PhotoSlot + VehicleScheme + InspectionEditor + InspectionReview
- Phase J (4 tasks): e2e test + deploy backend + deploy frontend + smoke test
≈ 20 tasks left until MVP live on mechanic.pptaxi.ru. Backend MVP is feature-complete after F.
≈ 25 tasks left until MVP live on mechanic.pptaxi.ru.
Known external dependencies
- MinIO
pp-inspectionsbucket — CORS already configured, presigned uploads will work - Caddy on 100.64.0.12 — fragment ready, will be wired during Phase J deploy
- Docker blue/green deployment on VDS — backend re-deployment will need a touch of
docker compose(Phase J) - Frontend deployment target:
/opt/sites/mechanic-pwa/dist/on VDS (Phase J)
Conventions reminder
- All new code in
feat/mechanic-mvpbranches in both repos - All new tables use
mechanic_prefix - Don't touch existing
app/api/inspections.pyorapp/models/inspection.py(vendor-imported) - All new mechanic source lives in flat files under
backend/app/api/withmechanic_*naming (mechanic.py,mechanic_schemas.py,mechanic_deps.py). Noapp/mechanic/subpackage — see "Plan-vs-reality overrides" table above. - Router prefix is
/v1/mechanic;/apiis added byinclude_router(..., prefix="/api")inmain.py. Final URLs always/api/v1/mechanic/*. - Phase C+ endpoint tests: pending the test-infrastructure decision (Option A / B / C above). Do NOT introduce
pytest-asyncio,conftest.py, or a real DB without first resolving that decision. require_mechanicallows admins + members of any activeDepartmentwithdept_type='svc'. Use it as the default auth dep for all mechanic endpoints.