Merge pull request 'feat(tradein): backend загрузки фото квартиры (#394)' (#407) from feat/tradein-photo-upload into main
This commit is contained in:
commit
a904037f95
4 changed files with 147 additions and 2 deletions
|
|
@ -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"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
tradein-mvp/backend/data/sql/007_estimate_photos.sql
Normal file
23
tradein-mvp/backend/data/sql/007_estimate_photos.sql
Normal 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;
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue