From 7250aa6187df51ee2fb3c2135c145e6d62c4c0ff Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Fri, 15 May 2026 00:40:06 +0300 Subject: [PATCH] feat(site-finder): weight profiles endpoints + analyze integration (#114 sub-PR 3/4) (#138) Admin CRUD router + non-breaking analyze_parcel integration. 46 tests pass. Refs: #114 Co-authored-by: lekss361 --- backend/app/api/v1/admin_weight_profiles.py | 102 ++++++ backend/app/api/v1/parcels.py | 27 +- backend/app/main.py | 16 +- backend/tests/test_admin_weight_profiles.py | 341 ++++++++++++++++++++ 4 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/v1/admin_weight_profiles.py create mode 100644 backend/tests/test_admin_weight_profiles.py diff --git a/backend/app/api/v1/admin_weight_profiles.py b/backend/app/api/v1/admin_weight_profiles.py new file mode 100644 index 00000000..96b6d9b3 --- /dev/null +++ b/backend/app/api/v1/admin_weight_profiles.py @@ -0,0 +1,102 @@ +"""Admin endpoints для управления weight profiles. + +Per #114 (Макс feedback: садики ≠ Мегамарт). Endpoints под X-Admin-Token. + +GET /api/v1/admin/site-finder/weight-profiles?user_id=... → list +POST /api/v1/admin/site-finder/weight-profiles → create +GET /api/v1/admin/site-finder/weight-profiles/{id}?user_id= → get one +PUT /api/v1/admin/site-finder/weight-profiles/{id}?user_id= → update +DELETE /api/v1/admin/site-finder/weight-profiles/{id}?user_id= → delete +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.core.deps import AdminTokenAuth +from app.services.site_finder.weight_profiles import ( + WeightProfile, + WeightProfileCreate, + WeightProfileUpdate, + create_profile, + delete_profile, + get_profile, + list_profiles, + update_profile, +) + +router = APIRouter() + + +@router.get("", response_model=list[WeightProfile]) +def list_user_profiles( + user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")], + db: Annotated[Session, Depends(get_db)], + _: AdminTokenAuth, +) -> Any: + """Список всех weight profiles для заданного user_id. Default-профиль первым.""" + return list_profiles(db, user_id) + + +@router.post("", response_model=WeightProfile, status_code=status.HTTP_201_CREATED) +def create_user_profile( + payload: WeightProfileCreate, + db: Annotated[Session, Depends(get_db)], + _: AdminTokenAuth, +) -> Any: + """Создать новый weight profile. + + При is_default=True снимает is_default у всех существующих профилей + пользователя в одной транзакции. + """ + return create_profile(db, payload) + + +@router.get("/{profile_id}", response_model=WeightProfile) +def get_user_profile( + profile_id: int, + user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")], + db: Annotated[Session, Depends(get_db)], + _: AdminTokenAuth, +) -> Any: + """Получить один weight profile по id (scoped к user_id).""" + profile = get_profile(db, user_id, profile_id) + if profile is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") + return profile + + +@router.put("/{profile_id}", response_model=WeightProfile) +def update_user_profile( + profile_id: int, + user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")], + payload: WeightProfileUpdate, + db: Annotated[Session, Depends(get_db)], + _: AdminTokenAuth, +) -> Any: + """PATCH-style обновление weight profile. + + Передавай только изменяемые поля. Не переданные поля остаются прежними. + При is_default=True снимает is_default у остальных профилей пользователя. + """ + profile = update_profile(db, user_id, profile_id, payload) + if profile is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") + return profile + + +@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user_profile( + profile_id: int, + user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")], + db: Annotated[Session, Depends(get_db)], + _: AdminTokenAuth, +) -> None: + """Удалить weight profile. 404 если не найден.""" + deleted = delete_profile(db, user_id, profile_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 60d0ee4d..2b480d1a 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1017,6 +1017,14 @@ def analyze_parcel( cad_num: str, db: Annotated[Session, Depends(get_db)], response: Response, + profile_id: Annotated[ + int | None, + Query(ge=1, description="Переопределить веса POI через конкретный weight profile"), + ] = None, + profile_user_id: Annotated[ + str | None, + Query(description="user_id для fallback на default-профиль пользователя"), + ] = None, ) -> dict[str, Any]: """Анализ участка: близость к социалке + district context + конкуренты. @@ -1192,6 +1200,16 @@ def analyze_parcel( .all() ) + # 3b) Resolve effective POI weights (profile → user default → system) + from app.services.site_finder.weight_profiles import resolve_weights as _resolve_weights + + _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) + _weights_source = ( + "profile" + if profile_id is not None + else ("user_default" if profile_user_id is not None else "system") + ) + # 4) Scoring: weighted sum с distance decay score = 0.0 by_category: dict[str, list[dict[str, Any]]] = {} @@ -1199,7 +1217,7 @@ def analyze_parcel( factors_detailed: list[dict[str, Any]] = [] for idx, p in enumerate(poi_rows): cat: str = p["category"] - w = _POI_WEIGHTS.get(cat, 0.0) + w = _effective_weights.get(cat, _POI_WEIGHTS.get(cat, 0.0)) # distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м distance_m = float(p["distance_m"]) decay = max(0.0, 1.0 - distance_m / 1000.0) @@ -1852,6 +1870,13 @@ def analyze_parcel( "nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"], "nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"], "nspd_dump": nspd_dump_data["nspd_dump"], + # #114: кастомные веса POI — source + applied dict для прозрачности. + "weights_profile": { + "source": _weights_source, + "profile_id": profile_id, + "user_id": profile_user_id, + "weights_applied": _effective_weights, + }, } diff --git a/backend/app/main.py b/backend/app/main.py index 541875af..55ddcd2e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,16 @@ import sentry_sdk from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.v1 import admin_jobs, admin_leads, admin_scrape, analytics, concepts, parcels, photos +from app.api.v1 import ( + admin_jobs, + admin_leads, + admin_scrape, + admin_weight_profiles, + analytics, + concepts, + parcels, + photos, +) from app.core.config import settings @@ -37,6 +46,11 @@ app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytic app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"]) app.include_router(admin_jobs.router, prefix="/api/v1/admin/jobs", tags=["admin"]) app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"]) +app.include_router( + admin_weight_profiles.router, + prefix="/api/v1/admin/site-finder/weight-profiles", + tags=["admin", "site-finder"], +) app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"]) diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py new file mode 100644 index 00000000..01caa3ed --- /dev/null +++ b/backend/tests/test_admin_weight_profiles.py @@ -0,0 +1,341 @@ +"""Tests для admin weight profiles endpoints. + +Покрывает: +- GET /api/v1/admin/site-finder/weight-profiles → 200 + list +- POST → 201 + created profile +- GET /{id} → 200 / 404 +- PUT /{id} → 200 / 404 +- DELETE /{id} → 204 / 404 +- 401 при отсутствии X-Admin-Token +- 422 при невалидных weights (неизвестная категория, вес вне диапазона) + +Mock-based: get_db переопределяется через dependency override. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.core.db import get_db +from app.main import app +from app.services.site_finder.weight_profiles import WeightProfile + +_ADMIN_TOKEN = "test-admin-token" +_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN} + +_NOW = datetime.now(UTC) + + +def _make_profile( + profile_id: int = 1, + user_id: str = "user-1", + profile_name: str = "Test", + weights: dict | None = None, + is_default: bool = False, +) -> WeightProfile: + return WeightProfile( + id=profile_id, + user_id=user_id, + profile_name=profile_name, + weights=weights or {"school": 1.5, "park": 1.8}, + is_default=is_default, + description=None, + created_at=_NOW, + updated_at=_NOW, + ) + + +@pytest.fixture() +def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient: + """TestClient с переопределённым SCRAPE_ADMIN_TOKEN.""" + monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) + return TestClient(app) + + +@pytest.fixture() +def mock_db() -> MagicMock: + return MagicMock() + + +def _override_db(mock: MagicMock): + def _get_db_override(): + yield mock + + app.dependency_overrides[get_db] = _get_db_override + return mock + + +def _clear_overrides(): + app.dependency_overrides.pop(get_db, None) + + +# ── GET list ─────────────────────────────────────────────────────────────────── + + +def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """GET ?user_id= → 200 + пустой список.""" + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.list_profiles", + lambda db, user_id: [], + ) + r = client_with_token.get( + "/api/v1/admin/site-finder/weight-profiles", + params={"user_id": "user-x"}, + headers=_HEADERS, + ) + assert r.status_code == 200 + assert r.json() == [] + finally: + _clear_overrides() + + +def test_list_returns_profiles( + client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """GET ?user_id= → 200 + список профилей.""" + profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")] + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.list_profiles", + lambda db, user_id: profiles, + ) + r = client_with_token.get( + "/api/v1/admin/site-finder/weight-profiles", + params={"user_id": "user-1"}, + headers=_HEADERS, + ) + assert r.status_code == 200 + body = r.json() + assert len(body) == 2 + assert body[0]["id"] == 1 + finally: + _clear_overrides() + + +# ── POST create ──────────────────────────────────────────────────────────────── + + +def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """POST создаёт профиль, возвращает его со статусом 201.""" + created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5}) + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.create_profile", + lambda db, payload: created, + ) + r = client_with_token.post( + "/api/v1/admin/site-finder/weight-profiles", + json={ + "user_id": "user-1", + "profile_name": "Семейный", + "weights": {"school": 2.0, "park": 1.5}, + "is_default": False, + }, + headers=_HEADERS, + ) + assert r.status_code == 201 + body = r.json() + assert body["id"] == 42 + assert body["profile_name"] == "Семейный" + assert body["weights"]["school"] == 2.0 + finally: + _clear_overrides() + + +def test_create_validation_unknown_category(client_with_token: TestClient) -> None: + """POST с неизвестной POI-категорией → 422 (Pydantic validation).""" + r = client_with_token.post( + "/api/v1/admin/site-finder/weight-profiles", + json={ + "user_id": "user-1", + "profile_name": "Bad", + "weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES + }, + headers=_HEADERS, + ) + assert r.status_code == 422 + + +def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None: + """POST с весом вне [-2, 3] → 422.""" + r = client_with_token.post( + "/api/v1/admin/site-finder/weight-profiles", + json={ + "user_id": "user-1", + "profile_name": "Bad", + "weights": {"school": 99.0}, + }, + headers=_HEADERS, + ) + assert r.status_code == 422 + + +# ── GET one ──────────────────────────────────────────────────────────────────── + + +def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """GET /{id}?user_id= → 200.""" + profile = _make_profile(7) + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.get_profile", + lambda db, user_id, profile_id: profile, + ) + r = client_with_token.get( + "/api/v1/admin/site-finder/weight-profiles/7", + params={"user_id": "user-1"}, + headers=_HEADERS, + ) + assert r.status_code == 200 + assert r.json()["id"] == 7 + finally: + _clear_overrides() + + +def test_get_profile_not_found( + client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """GET /{id} несуществующего профиля → 404.""" + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.get_profile", + lambda db, user_id, profile_id: None, + ) + r = client_with_token.get( + "/api/v1/admin/site-finder/weight-profiles/999", + params={"user_id": "user-1"}, + headers=_HEADERS, + ) + assert r.status_code == 404 + finally: + _clear_overrides() + + +# ── PUT update ───────────────────────────────────────────────────────────────── + + +def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """PUT /{id} → 200 + обновлённый профиль.""" + updated = _make_profile(3, profile_name="Обновлённый") + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.update_profile", + lambda db, user_id, profile_id, payload: updated, + ) + r = client_with_token.put( + "/api/v1/admin/site-finder/weight-profiles/3", + params={"user_id": "user-1"}, + json={"profile_name": "Обновлённый"}, + headers=_HEADERS, + ) + assert r.status_code == 200 + assert r.json()["profile_name"] == "Обновлённый" + finally: + _clear_overrides() + + +def test_update_profile_not_found( + client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """PUT /{id} несуществующего → 404.""" + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.update_profile", + lambda db, user_id, profile_id, payload: None, + ) + r = client_with_token.put( + "/api/v1/admin/site-finder/weight-profiles/999", + params={"user_id": "user-1"}, + json={"profile_name": "X"}, + headers=_HEADERS, + ) + assert r.status_code == 404 + finally: + _clear_overrides() + + +# ── DELETE ───────────────────────────────────────────────────────────────────── + + +def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """DELETE /{id} → 204.""" + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.delete_profile", + lambda db, user_id, profile_id: True, + ) + r = client_with_token.delete( + "/api/v1/admin/site-finder/weight-profiles/5", + params={"user_id": "user-1"}, + headers=_HEADERS, + ) + assert r.status_code == 204 + finally: + _clear_overrides() + + +def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """DELETE /{id} несуществующего → 404.""" + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.delete_profile", + lambda db, user_id, profile_id: False, + ) + r = client_with_token.delete( + "/api/v1/admin/site-finder/weight-profiles/999", + params={"user_id": "user-1"}, + headers=_HEADERS, + ) + assert r.status_code == 404 + finally: + _clear_overrides() + + +# ── Auth ─────────────────────────────────────────────────────────────────────── + + +def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None: + """Запрос без X-Admin-Token → 401 (или 503 если токен не задан в settings).""" + monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) + client = TestClient(app) + r = client.get( + "/api/v1/admin/site-finder/weight-profiles", + params={"user_id": "user-1"}, + # без headers — нет X-Admin-Token + ) + assert r.status_code == 401 + + +def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None: + """Неверный X-Admin-Token → 401.""" + monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) + client = TestClient(app) + r = client.get( + "/api/v1/admin/site-finder/weight-profiles", + params={"user_id": "user-1"}, + headers={"X-Admin-Token": "wrong-token"}, + ) + assert r.status_code == 401