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:
parent
e4852b0ec9
commit
a6e4ff0407
4 changed files with 222 additions and 1 deletions
|
|
@ -26,6 +26,7 @@ from app.services.site_finder.weight_profiles import (
|
||||||
delete_profile,
|
delete_profile,
|
||||||
get_profile,
|
get_profile,
|
||||||
list_profiles,
|
list_profiles,
|
||||||
|
list_profiles_with_system,
|
||||||
update_profile,
|
update_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,8 +38,18 @@ def list_user_profiles(
|
||||||
user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")],
|
user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
_: AdminTokenAuth,
|
_: AdminTokenAuth,
|
||||||
|
include_system: Annotated[
|
||||||
|
bool,
|
||||||
|
Query(description="Включить системные preset-профили (Эконом/Комфорт/Бизнес)"),
|
||||||
|
] = False,
|
||||||
) -> Any:
|
) -> 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)
|
return list_profiles(db, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ from sqlalchemy import text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 POI categories — single source of truth; imported by api/v1/parcels.py
|
||||||
ALLOWED_CATEGORIES: set[str] = {
|
ALLOWED_CATEGORIES: set[str] = {
|
||||||
"school",
|
"school",
|
||||||
|
|
@ -74,6 +78,17 @@ _SELECT_BY_USER = f"""
|
||||||
ORDER BY is_default DESC, id ASC
|
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_BY_ID = f"""
|
||||||
SELECT {_SELECT_COLS}
|
SELECT {_SELECT_COLS}
|
||||||
FROM user_weight_profiles
|
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]
|
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:
|
def get_profile(db: Any, user_id: str, profile_id: int) -> WeightProfile | None:
|
||||||
"""Вернуть профиль по id (scoped к пользователю)."""
|
"""Вернуть профиль по id (scoped к пользователю)."""
|
||||||
row = (
|
row = (
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,93 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
|
||||||
_clear_overrides()
|
_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 ───────────────────────────────────────────────────────────────────────
|
# ── Auth ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
90
data/sql/100_user_weight_profiles_default_seed.sql
Normal file
90
data/sql/100_user_weight_profiles_default_seed.sql
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue