feat(#114): seed 3 default weight presets + include_system API param

Add SQL migration 100_user_weight_profiles_default_seed.sql with system
presets Эконом/Комфорт/Бизнес (user_id='__system__'). Migration is
idempotent via ON CONFLICT DO UPDATE.

Backend:
- weight_profiles.py: add SYSTEM_USER_ID constant + list_profiles_with_system()
- admin_weight_profiles.py: add include_system query param to GET list endpoint

Tests: 3 new tests covering include_system flag and service sentinel behaviour.
This commit is contained in:
lekss361 2026-05-16 22:49:56 +03:00
parent e4852b0ec9
commit a6e4ff0407
4 changed files with 222 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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