diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index b9babe64..83d76bae 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -14,12 +14,12 @@ from datetime import UTC, datetime, timedelta from typing import Annotated from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import get_db -from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput +from app.schemas.trade_in import AggregatedEstimate, AnalogLot, PhotoMeta, TradeInEstimateInput from app.services.exporters.trade_in_pdf import generate_trade_in_pdf logger = logging.getLogger(__name__) @@ -409,3 +409,114 @@ def estimate_pdf( media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) + + +# ── Фото квартиры (#394) ───────────────────────────────────────────────────── +_MAX_PHOTO_BYTES = 10 * 1024 * 1024 # 10 МБ на фото +_MAX_PHOTOS_PER_ESTIMATE = 12 +_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"} + + +@router.post("/estimate/{estimate_id}/photos", response_model=PhotoMeta) +async def upload_photo( + estimate_id: UUID, + db: Annotated[Session, Depends(get_db)], + file: Annotated[UploadFile, File()], +) -> PhotoMeta: + """Загрузить фото квартиры к оценке (#394). Хранение в estimate_photos (bytea).""" + estimate = db.execute( + text("SELECT 1 FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(estimate_id)}, + ).fetchone() + if estimate is None: + raise HTTPException(status_code=404, detail="estimate not found") + + ctype = (file.content_type or "").lower() + if ctype not in _ALLOWED_IMAGE_TYPES: + raise HTTPException( + status_code=415, detail=f"unsupported content-type: {ctype or 'unknown'}" + ) + + count = db.execute( + text("SELECT count(*) FROM estimate_photos WHERE estimate_id = CAST(:id AS uuid)"), + {"id": str(estimate_id)}, + ).scalar_one() + if count >= _MAX_PHOTOS_PER_ESTIMATE: + raise HTTPException( + status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})" + ) + + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="empty file") + if len(content) > _MAX_PHOTO_BYTES: + raise HTTPException(status_code=413, detail="file too large (max 10 MB)") + + row = db.execute( + text( + """ + INSERT INTO estimate_photos + (estimate_id, filename, content_type, content, size_bytes) + VALUES (CAST(:eid AS uuid), :fn, :ct, :content, :sz) + RETURNING id, filename, content_type, size_bytes, uploaded_at + """ + ), + { + "eid": str(estimate_id), + "fn": file.filename, + "ct": ctype, + "content": content, + "sz": len(content), + }, + ).mappings().fetchone() + db.commit() + logger.info( + "photo uploaded: estimate=%s photo=%s size=%d", estimate_id, row["id"], len(content) + ) + return PhotoMeta(**row) + + +@router.get("/estimate/{estimate_id}/photos", response_model=list[PhotoMeta]) +def list_photos( + estimate_id: UUID, + db: Annotated[Session, Depends(get_db)], +) -> list[PhotoMeta]: + """Список фото оценки — метаданные, без содержимого (#394).""" + rows = db.execute( + text( + """ + SELECT id, filename, content_type, size_bytes, uploaded_at + FROM estimate_photos + WHERE estimate_id = CAST(:id AS uuid) + ORDER BY uploaded_at + """ + ), + {"id": str(estimate_id)}, + ).mappings().all() + return [PhotoMeta(**r) for r in rows] + + +@router.get("/estimate/{estimate_id}/photos/{photo_id}") +def get_photo( + estimate_id: UUID, + photo_id: UUID, + db: Annotated[Session, Depends(get_db)], +) -> Response: + """Отдать содержимое фото — image bytes (#394).""" + row = db.execute( + text( + """ + SELECT content, content_type + FROM estimate_photos + WHERE id = CAST(:pid AS uuid) AND estimate_id = CAST(:eid AS uuid) + """ + ), + {"pid": str(photo_id), "eid": str(estimate_id)}, + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="photo not found") + return Response( + content=bytes(row.content), + media_type=row.content_type, + headers={"Cache-Control": "private, max-age=3600"}, + ) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 088bad27..fff895ee 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -61,3 +61,13 @@ class AggregatedEstimate(BaseModel): sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr'] data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам) + + +class PhotoMeta(BaseModel): + """Метаданные фото квартиры (#394). Содержимое отдаётся отдельным эндпоинтом.""" + + id: UUID + filename: str | None = None + content_type: str + size_bytes: int | None = None + uploaded_at: datetime diff --git a/tradein-mvp/backend/data/sql/007_estimate_photos.sql b/tradein-mvp/backend/data/sql/007_estimate_photos.sql new file mode 100644 index 00000000..cf8b2705 --- /dev/null +++ b/tradein-mvp/backend/data/sql/007_estimate_photos.sql @@ -0,0 +1,23 @@ +-- Trade-In MVP — фото квартиры, прикреплённые к оценке (#394). +-- Хранение в bytea (без отдельного файлового тома): фото идут в PDF-отчёт +-- и путешествуют вместе с бэкапом БД (#397). Привязка к trade_in_estimates. + +BEGIN; + +CREATE TABLE IF NOT EXISTS estimate_photos ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + estimate_id uuid NOT NULL REFERENCES trade_in_estimates(id) ON DELETE CASCADE, + filename text, + content_type text NOT NULL, + content bytea NOT NULL, + size_bytes int, + uploaded_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS estimate_photos_estimate_idx + ON estimate_photos (estimate_id, uploaded_at); + +COMMENT ON TABLE estimate_photos IS + '#394 — фото квартиры, привязанные к trade_in_estimates. Хранение в bytea.'; + +COMMIT; diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml index b74a88fe..68dfe8fa 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "selectolax>=0.3.0", # быстрый HTML парсинг для scrapers "segno>=1.6.0", # QR-код для PDF shareable URL "curl-cffi>=0.7.0", # impersonate=chrome120 для Cian/Avito (TLS fingerprint) + "python-multipart>=0.0.9", # FastAPI UploadFile — загрузка фото квартиры (#394) ] [dependency-groups]