feat(tradein): backend загрузки фото квартиры (#394) #407

Merged
lekss361 merged 1 commit from feat/tradein-photo-upload into main 2026-05-22 06:06:11 +00:00
4 changed files with 147 additions and 2 deletions
Showing only changes of commit 5413cce953 - Show all commits

View file

@ -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"},
)

View file

@ -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

View file

@ -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;

View file

@ -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]