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 typing import Annotated
|
||||||
from uuid import UUID, uuid4
|
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 import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db
|
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
|
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -409,3 +409,114 @@ def estimate_pdf(
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
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']
|
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
||||||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||||
est_days_on_market: 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
|
"selectolax>=0.3.0", # быстрый HTML парсинг для scrapers
|
||||||
"segno>=1.6.0", # QR-код для PDF shareable URL
|
"segno>=1.6.0", # QR-код для PDF shareable URL
|
||||||
"curl-cffi>=0.7.0", # impersonate=chrome120 для Cian/Avito (TLS fingerprint)
|
"curl-cffi>=0.7.0", # impersonate=chrome120 для Cian/Avito (TLS fingerprint)
|
||||||
|
"python-multipart>=0.0.9", # FastAPI UploadFile — загрузка фото квартиры (#394)
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue