diff --git a/backend/app/api/v1/admin_weight_profiles.py b/backend/app/api/v1/admin_weight_profiles.py index 96b6d9b3..fc187596 100644 --- a/backend/app/api/v1/admin_weight_profiles.py +++ b/backend/app/api/v1/admin_weight_profiles.py @@ -26,6 +26,7 @@ from app.services.site_finder.weight_profiles import ( delete_profile, get_profile, list_profiles, + list_profiles_with_system, update_profile, ) @@ -37,8 +38,18 @@ def list_user_profiles( user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")], db: Annotated[Session, Depends(get_db)], _: AdminTokenAuth, + include_system: Annotated[ + bool, + Query(description="Включить системные preset-профили (Эконом/Комфорт/Бизнес)"), + ] = False, ) -> Any: - """Список всех weight profiles для заданного user_id. Default-профиль первым.""" + """Список всех weight profiles для заданного user_id. Default-профиль первым. + + При include_system=true добавляются системные presets (user_id='__system__'): + Эконом, Комфорт, Бизнес — готовые шаблоны весов POI. + """ + if include_system: + return list_profiles_with_system(db, user_id) return list_profiles(db, user_id) diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 12259896..08a253d1 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -26,6 +26,10 @@ from sqlalchemy import text logger = logging.getLogger(__name__) +# Sentinel user_id для системных preset-профилей (не привязаны к реальному пользователю). +# Seed: data/sql/100_user_weight_profiles_default_seed.sql +SYSTEM_USER_ID: str = "__system__" + # Allowed POI categories — single source of truth; imported by api/v1/parcels.py ALLOWED_CATEGORIES: set[str] = { "school", @@ -74,6 +78,17 @@ _SELECT_BY_USER = f""" ORDER BY is_default DESC, id ASC """ +_SELECT_BY_USER_WITH_SYSTEM = f""" + SELECT {_SELECT_COLS} + FROM user_weight_profiles + WHERE user_id = :user_id + OR user_id = :system_user_id + ORDER BY + CASE WHEN user_id = :system_user_id THEN 1 ELSE 0 END ASC, + is_default DESC, + id ASC +""" + _SELECT_BY_ID = f""" SELECT {_SELECT_COLS} FROM user_weight_profiles @@ -191,6 +206,24 @@ def list_profiles(db: Any, user_id: str) -> list[WeightProfile]: return [_row_to_profile(r) for r in rows] +def list_profiles_with_system(db: Any, user_id: str) -> list[WeightProfile]: + """Вернуть профили пользователя + системные preset-профили. + + Пользовательские профили идут первыми (default сверху), затем системные + presets (Эконом, Комфорт, Бизнес). Предназначен для endpoint с + include_system=true — UI dropdown видит и пользовательские, и preset. + """ + rows = ( + db.execute( + text(_SELECT_BY_USER_WITH_SYSTEM), + {"user_id": user_id, "system_user_id": SYSTEM_USER_ID}, + ) + .mappings() + .all() + ) + return [_row_to_profile(r) for r in rows] + + def get_profile(db: Any, user_id: str, profile_id: int) -> WeightProfile | None: """Вернуть профиль по id (scoped к пользователю).""" row = ( diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py index 01caa3ed..c3bad23a 100644 --- a/backend/tests/test_admin_weight_profiles.py +++ b/backend/tests/test_admin_weight_profiles.py @@ -314,6 +314,93 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon _clear_overrides() +# ── include_system preset seed (Issue #114 / PR #229) ───────────────────────── + + +def test_list_include_system_calls_with_system( + client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """GET ?include_system=true вызывает list_profiles_with_system, возвращает presets.""" + system_profile = _make_profile( + 100, user_id="__system__", profile_name="Комфорт", weights={"park": 1.8, "school": 1.5} + ) + user_profile = _make_profile(1, user_id="user-1", profile_name="Мой профиль") + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.list_profiles_with_system", + lambda db, user_id: [user_profile, system_profile], + ) + r = client_with_token.get( + "/api/v1/admin/site-finder/weight-profiles", + params={"user_id": "user-1", "include_system": "true"}, + headers=_HEADERS, + ) + assert r.status_code == 200 + body = r.json() + assert len(body) == 2 + profile_names = {p["profile_name"] for p in body} + assert "Комфорт" in profile_names + assert "Мой профиль" in profile_names + finally: + _clear_overrides() + + +def test_list_without_include_system_does_not_call_with_system( + client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """GET без include_system → list_profiles (только пользовательские профили).""" + user_profile = _make_profile(1, user_id="user-1") + called_with_system = [] + mock = MagicMock() + _override_db(mock) + try: + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.list_profiles", + lambda db, user_id: [user_profile], + ) + monkeypatch.setattr( + "app.api.v1.admin_weight_profiles.list_profiles_with_system", + lambda db, user_id: called_with_system.append(True) or [], + ) + r = client_with_token.get( + "/api/v1/admin/site-finder/weight-profiles", + params={"user_id": "user-1"}, + headers=_HEADERS, + ) + assert r.status_code == 200 + assert len(r.json()) == 1 + # list_profiles_with_system НЕ должен вызываться без include_system=true + assert called_with_system == [], "вызван list_profiles_with_system без флага" + finally: + _clear_overrides() + + +def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> None: + """list_profiles_with_system передаёт system_user_id='__system__' в запрос.""" + from app.services.site_finder.weight_profiles import SYSTEM_USER_ID, list_profiles_with_system + + captured_params: list[dict] = [] + + class FakeResult: + def mappings(self) -> FakeResult: + return self + + def all(self) -> list: + return [] + + class FakeDb: + def execute(self, query: object, params: dict) -> FakeResult: + captured_params.append(params) + return FakeResult() + + list_profiles_with_system(FakeDb(), user_id="user-test") + assert len(captured_params) == 1 + assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID + assert captured_params[0]["user_id"] == "user-test" + + # ── Auth ─────────────────────────────────────────────────────────────────────── diff --git a/data/sql/100_user_weight_profiles_default_seed.sql b/data/sql/100_user_weight_profiles_default_seed.sql new file mode 100644 index 00000000..546061cf --- /dev/null +++ b/data/sql/100_user_weight_profiles_default_seed.sql @@ -0,0 +1,90 @@ +-- 100_user_weight_profiles_default_seed.sql +-- Системные preset-профили весов POI для Site Finder. +-- Per #114 (Макс feedback): садики и Мегамарт должны иметь разные веса. +-- +-- Apply order: после 90_user_weight_profiles.sql (таблица уже существует) +-- Dependencies: user_weight_profiles table +-- +-- Sentinel owner: '__system__' — не является реальным user_id, +-- используется только для системных presets. +-- UI: dropdown показывает system presets всем пользователям через +-- GET /api/v1/admin/site-finder/weight-profiles?user_id=__system__ +-- или include_system=true (добавлен в #114 seed PR). +-- +-- Idempotent: ON CONFLICT (user_id, profile_name) DO UPDATE — обновляет +-- веса если preset уже существует (safe re-apply на prod). + +BEGIN; + +INSERT INTO user_weight_profiles + (user_id, profile_name, weights, is_default, description) +VALUES + ( + '__system__', + 'Эконом', + '{ + "school": 1.2, + "kindergarten": 1.0, + "pharmacy": 0.8, + "hospital": 0.5, + "shop_mall": 0.8, + "shop_supermarket": 1.5, + "shop_small": 1.0, + "park": 1.2, + "bus_stop": 1.0, + "metro_stop": 0.8, + "tram_stop": 0.5 + }'::jsonb, + FALSE, + 'Эконом-класс: доступность магазинов и транспорта важнее премиальной инфраструктуры' + ), + ( + '__system__', + 'Комфорт', + '{ + "school": 1.5, + "kindergarten": 1.8, + "pharmacy": 0.8, + "hospital": 0.6, + "shop_mall": 1.0, + "shop_supermarket": 1.2, + "shop_small": 0.5, + "park": 1.8, + "bus_stop": 0.5, + "metro_stop": 1.2, + "tram_stop": -0.3 + }'::jsonb, + FALSE, + 'Комфорт-класс: парки, детсады и школы в приоритете — семейная аудитория' + ), + ( + '__system__', + 'Бизнес', + '{ + "school": 1.0, + "kindergarten": 0.8, + "pharmacy": 0.6, + "hospital": 0.5, + "shop_mall": 2.0, + "shop_supermarket": 0.8, + "shop_small": 0.3, + "park": 2.0, + "bus_stop": 0.2, + "metro_stop": 2.5, + "tram_stop": -0.5 + }'::jsonb, + FALSE, + 'Бизнес-класс: метро, ТЦ и парки в приоритете — платёжеспособная аудитория' + ) +ON CONFLICT (user_id, profile_name) +DO UPDATE SET + weights = EXCLUDED.weights, + description = EXCLUDED.description, + updated_at = NOW(); + +COMMENT ON TABLE user_weight_profiles IS + 'POI weight profiles для site_finder. Per #114 (Макс feedback). ' + 'weights = {"school": 1.5, "kindergarten": 1.5, "shop_mall": 1.2, ...}. ' + 'Системные presets: user_id = ''__system__'' (3 preset: Эконом/Комфорт/Бизнес).'; + +COMMIT;