feat(site-finder): weight profiles service — Pydantic + CRUD (#114 sub-PR 2/4) (#137)

Per #114 sub-PR 2/4. Pydantic v2 models + CRUD service. 14 passing tests.

API: list/get/get_default/create/update/delete_profile + resolve_weights
fallback to system defaults.

Validation: ALLOWED_CATEGORIES guard + weight bounds [-2.0, 3.0].
Default-uniqueness в одной transaction (job_settings.py pattern).

Vault: code/modules/Module_Weight_Profiles_Service.md.

Refs: #114

Co-authored-by: lekss361 <claudestars@proton.me>
This commit is contained in:
lekss361 2026-05-15 00:29:37 +03:00 committed by GitHub
parent f3f1e7f6bc
commit 5aea78c2d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 553 additions and 0 deletions

View file

@ -0,0 +1,338 @@
"""User weight profiles для Site Finder POI scoring.
Per #114 (Макс feedback): кастомизируемые веса для каждой POI категории.
Table schema: data/sql/90_user_weight_profiles.sql.
API surface:
- list_profiles(db, user_id) list[WeightProfile]
- get_profile(db, user_id, profile_id) WeightProfile | None
- get_default_profile(db, user_id) WeightProfile | None
- create_profile(db, payload) WeightProfile
- update_profile(db, user_id, profile_id, payload) WeightProfile | None
- delete_profile(db, user_id, profile_id) bool
- resolve_weights(db, user_id, profile_id) dict[str, float]
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
logger = logging.getLogger(__name__)
# Allowed POI categories — mirrors _POI_WEIGHTS keys in api/v1/parcels.py
ALLOWED_CATEGORIES: set[str] = {
"school",
"kindergarten",
"pharmacy",
"hospital",
"shop_mall",
"shop_supermarket",
"shop_small",
"park",
"bus_stop",
"metro_stop",
"tram_stop",
}
# Weight value bounds (per #114 spec)
MIN_WEIGHT: float = -2.0
MAX_WEIGHT: float = 3.0
# System defaults — keep in sync with _POI_WEIGHTS in parcels.py
_SYSTEM_POI_WEIGHTS: dict[str, float] = {
"school": 1.5,
"kindergarten": 1.5,
"pharmacy": 0.8,
"hospital": 0.6,
"shop_mall": 1.2,
"shop_supermarket": 1.0,
"shop_small": 0.5,
"park": 1.8,
"bus_stop": 0.3,
"metro_stop": 1.5,
"tram_stop": -0.5,
}
# ── SQL helpers ────────────────────────────────────────────────────────────────
_SELECT_COLS = """
id, user_id, profile_name, weights, is_default, description,
created_at, updated_at
"""
_SELECT_BY_USER = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
WHERE user_id = :user_id
ORDER BY is_default DESC, id ASC
"""
_SELECT_BY_ID = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
WHERE user_id = :user_id
AND id = :profile_id
"""
_SELECT_DEFAULT = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
WHERE user_id = :user_id
AND is_default = TRUE
LIMIT 1
"""
_UNSET_DEFAULT = """
UPDATE user_weight_profiles
SET is_default = FALSE
WHERE user_id = :user_id
AND is_default = TRUE
"""
_INSERT = """
INSERT INTO user_weight_profiles
(user_id, profile_name, weights, is_default, description)
VALUES
(:user_id, :profile_name, :weights::jsonb, :is_default, :description)
RETURNING id, created_at, updated_at
"""
# ── Pydantic models ────────────────────────────────────────────────────────────
def _validate_weights_dict(v: dict[str, float]) -> dict[str, float]:
"""Shared validation logic for weights dict."""
bad_keys = set(v.keys()) - ALLOWED_CATEGORIES
if bad_keys:
raise ValueError(f"Unknown POI categories: {sorted(bad_keys)}")
for k, w in v.items():
if not isinstance(w, int | float):
raise ValueError(f"Weight for '{k}' must be number, got {type(w).__name__}")
if w < MIN_WEIGHT or w > MAX_WEIGHT:
raise ValueError(f"Weight for '{k}' = {w} out of bounds [{MIN_WEIGHT}, {MAX_WEIGHT}]")
return v
class WeightProfileBase(BaseModel):
profile_name: str = Field(..., min_length=1, max_length=64)
weights: dict[str, float] = Field(default_factory=dict)
is_default: bool = False
description: str | None = None
@field_validator("weights")
@classmethod
def _validate_weights(cls, v: dict[str, float]) -> dict[str, float]:
return _validate_weights_dict(v)
class WeightProfileCreate(WeightProfileBase):
user_id: str = Field(..., min_length=1, max_length=128)
class WeightProfileUpdate(BaseModel):
profile_name: str | None = Field(None, min_length=1, max_length=64)
weights: dict[str, float] | None = None
is_default: bool | None = None
description: str | None = None
@field_validator("weights")
@classmethod
def _validate_weights(cls, v: dict[str, float] | None) -> dict[str, float] | None:
if v is None:
return v
return _validate_weights_dict(v)
class WeightProfile(WeightProfileBase):
id: int
user_id: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ── Row mapper ─────────────────────────────────────────────────────────────────
def _row_to_profile(r: Any) -> WeightProfile:
"""Map SQLAlchemy mapping row → WeightProfile."""
weights_raw = r["weights"]
if isinstance(weights_raw, str):
weights_raw = json.loads(weights_raw)
weights: dict[str, float] = weights_raw or {}
return WeightProfile(
id=r["id"],
user_id=r["user_id"],
profile_name=r["profile_name"],
weights=weights,
is_default=bool(r["is_default"]),
description=r["description"],
created_at=r["created_at"],
updated_at=r["updated_at"],
)
# ── CRUD service ───────────────────────────────────────────────────────────────
def list_profiles(db: Any, user_id: str) -> list[WeightProfile]:
"""Вернуть все профили пользователя, default первым."""
rows = db.execute(text(_SELECT_BY_USER), {"user_id": 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 = (
db.execute(text(_SELECT_BY_ID), {"user_id": user_id, "profile_id": profile_id})
.mappings()
.first()
)
if row is None:
return None
return _row_to_profile(row)
def get_default_profile(db: Any, user_id: str) -> WeightProfile | None:
"""Вернуть профиль-default пользователя или None."""
row = db.execute(text(_SELECT_DEFAULT), {"user_id": user_id}).mappings().first()
if row is None:
return None
return _row_to_profile(row)
def create_profile(db: Any, payload: WeightProfileCreate) -> WeightProfile:
"""Создать новый профиль.
Если is_default=True снять is_default у всех существующих профилей
пользователя в одной транзакции.
"""
if payload.is_default:
db.execute(text(_UNSET_DEFAULT), {"user_id": payload.user_id})
result = (
db.execute(
text(_INSERT),
{
"user_id": payload.user_id,
"profile_name": payload.profile_name,
"weights": json.dumps(payload.weights, ensure_ascii=False),
"is_default": payload.is_default,
"description": payload.description,
},
)
.mappings()
.first()
)
db.commit()
assert result is not None, "INSERT RETURNING вернул пустой результат"
return WeightProfile(
id=result["id"],
user_id=payload.user_id,
profile_name=payload.profile_name,
weights=payload.weights,
is_default=payload.is_default,
description=payload.description,
created_at=result["created_at"],
updated_at=result["updated_at"],
)
def update_profile(
db: Any, user_id: str, profile_id: int, payload: WeightProfileUpdate
) -> WeightProfile | None:
"""Обновить поля профиля (PATCH-style). Вернуть None если не найден.
При установке is_default=True снять is_default у остальных профилей
пользователя в одной транзакции.
"""
existing = get_profile(db, user_id, profile_id)
if existing is None:
return None
sets: list[str] = ["updated_at = NOW()"]
params: dict[str, Any] = {"user_id": user_id, "profile_id": profile_id}
if payload.profile_name is not None:
sets.append("profile_name = :profile_name")
params["profile_name"] = payload.profile_name
if payload.weights is not None:
sets.append("weights = :weights::jsonb")
params["weights"] = json.dumps(payload.weights, ensure_ascii=False)
if payload.description is not None:
sets.append("description = :description")
params["description"] = payload.description
if payload.is_default is not None:
sets.append("is_default = :is_default")
params["is_default"] = payload.is_default
if payload.is_default:
db.execute(text(_UNSET_DEFAULT), {"user_id": user_id})
if len(sets) > 1: # есть что обновлять кроме updated_at
db.execute(
text(
f"UPDATE user_weight_profiles SET {', '.join(sets)}"
" WHERE user_id = :user_id AND id = :profile_id"
),
params,
)
db.commit()
return get_profile(db, user_id, profile_id)
def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
"""Удалить профиль. Вернуть True если удалён, False если не найден."""
result = db.execute(
text(
"DELETE FROM user_weight_profiles"
" WHERE user_id = :user_id AND id = :profile_id"
" RETURNING id"
),
{"user_id": user_id, "profile_id": profile_id},
).first()
if result is None:
return False
db.commit()
return True
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
"""Вернуть эффективные веса для analyze_parcel.
Порядок приоритетов:
1. profile_id задан загрузить именно этот профиль
2. user_id задан загрузить default-профиль пользователя
3. Иначе вернуть системные значения _SYSTEM_POI_WEIGHTS
"""
if profile_id is not None and user_id is not None:
profile = get_profile(db, user_id, profile_id)
if profile is not None and profile.weights:
logger.debug(
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
)
return dict(profile.weights)
if user_id is not None:
profile = get_default_profile(db, user_id)
if profile is not None and profile.weights:
logger.debug("resolve_weights: user=%s → default profile weights", user_id)
return dict(profile.weights)
logger.debug("resolve_weights: returning system defaults")
return dict(_SYSTEM_POI_WEIGHTS)

View file

@ -0,0 +1,215 @@
"""Unit tests для WeightProfile Pydantic models + resolve_weights.
Mock-based без реальной БД. Проверяет:
- WeightProfileCreate: отклонение неизвестных категорий
- WeightProfileCreate: отклонение весов за пределами MIN/MAX
- WeightProfileCreate: корректные веса проходят валидацию
- WeightProfileUpdate: weights=None не вызывает ошибку
- resolve_weights: нет user_id и profile_id системные дефолты
- resolve_weights: user_id задан, default-профиль есть его веса
- resolve_weights: profile_id задан его веса
- resolve_weights: профиль не найден системные дефолты (fallback)
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from pydantic import ValidationError
from app.services.site_finder.weight_profiles import (
_SYSTEM_POI_WEIGHTS,
WeightProfile,
WeightProfileCreate,
WeightProfileUpdate,
resolve_weights,
)
# ── Validation tests ───────────────────────────────────────────────────────────
def test_validate_unknown_category() -> None:
"""Неизвестная POI-категория → ValidationError."""
with pytest.raises(ValidationError, match="Unknown POI categories"):
WeightProfileCreate(
user_id="user-1",
profile_name="test",
weights={"unknown": 1.0},
)
def test_validate_weight_bounds_too_high() -> None:
"""Вес > MAX_WEIGHT → ValidationError."""
with pytest.raises(ValidationError, match="out of bounds"):
WeightProfileCreate(
user_id="user-1",
profile_name="test",
weights={"school": 99.0},
)
def test_validate_weight_negative_too_low() -> None:
"""Вес < MIN_WEIGHT → ValidationError."""
with pytest.raises(ValidationError, match="out of bounds"):
WeightProfileCreate(
user_id="user-1",
profile_name="test",
weights={"tram_stop": -3.0},
)
def test_validate_valid_weights() -> None:
"""Корректные веса проходят валидацию без ошибок."""
profile = WeightProfileCreate(
user_id="user-1",
profile_name="my profile",
weights={"school": 1.5, "tram_stop": -0.5, "park": 2.9},
)
assert profile.weights["school"] == 1.5
assert profile.weights["tram_stop"] == -0.5
def test_validate_empty_weights() -> None:
"""Пустой dict weights допустим (используются системные дефолты)."""
profile = WeightProfileCreate(
user_id="user-1",
profile_name="empty",
weights={},
)
assert profile.weights == {}
def test_validate_boundary_values() -> None:
"""Граничные значения MIN_WEIGHT и MAX_WEIGHT включительно — допустимы."""
profile = WeightProfileCreate(
user_id="user-1",
profile_name="boundary",
weights={"school": -2.0, "park": 3.0},
)
assert profile.weights["school"] == -2.0
assert profile.weights["park"] == 3.0
def test_validate_update_weights_none() -> None:
"""WeightProfileUpdate с weights=None не вызывает ошибку валидации."""
upd = WeightProfileUpdate(weights=None)
assert upd.weights is None
def test_validate_update_unknown_category() -> None:
"""WeightProfileUpdate с неизвестной категорией → ValidationError."""
with pytest.raises(ValidationError, match="Unknown POI categories"):
WeightProfileUpdate(weights={"bogus_category": 1.0})
# ── resolve_weights tests ──────────────────────────────────────────────────────
def test_resolve_weights_system_default() -> None:
"""Оба аргумента None → возвращаются системные веса."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
assert result == _SYSTEM_POI_WEIGHTS
# db не должен вызываться вообще
db.execute.assert_not_called()
def test_resolve_weights_system_default_returns_copy() -> None:
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
result["school"] = 999.0
# Оригинал не изменён
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
def _make_profile_mock(weights: dict[str, float]) -> WeightProfile:
"""Вспомогательная функция: создать WeightProfile с заданными weights."""
from datetime import UTC, datetime
return WeightProfile(
id=1,
user_id="user-1",
profile_name="test",
weights=weights,
is_default=True,
description=None,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
def test_resolve_weights_uses_default_profile() -> None:
"""user_id задан, default-профиль найден → его веса."""
custom_weights = {"school": 2.0, "park": 0.5}
profile = _make_profile_mock(custom_weights)
db = MagicMock()
import app.services.site_finder.weight_profiles as wp_module
original = wp_module.get_default_profile
wp_module.get_default_profile = lambda _db, uid: profile
try:
result = resolve_weights(db, user_id="user-1", profile_id=None)
finally:
wp_module.get_default_profile = original
assert result == custom_weights
def test_resolve_weights_uses_specific_profile() -> None:
"""profile_id задан → веса конкретного профиля."""
custom_weights = {"kindergarten": 1.8, "metro_stop": 1.0}
profile = _make_profile_mock(custom_weights)
db = MagicMock()
import app.services.site_finder.weight_profiles as wp_module
original = wp_module.get_profile
wp_module.get_profile = lambda _db, uid, pid: profile
try:
result = resolve_weights(db, user_id="user-1", profile_id=42)
finally:
wp_module.get_profile = original
assert result == custom_weights
def test_resolve_weights_profile_not_found_fallback() -> None:
"""Профиль не найден (get_profile → None) → системные дефолты."""
db = MagicMock()
import app.services.site_finder.weight_profiles as wp_module
original_get = wp_module.get_profile
original_default = wp_module.get_default_profile
wp_module.get_profile = lambda _db, uid, pid: None
wp_module.get_default_profile = lambda _db, uid: None
try:
result = resolve_weights(db, user_id="user-1", profile_id=999)
finally:
wp_module.get_profile = original_get
wp_module.get_default_profile = original_default
assert result == _SYSTEM_POI_WEIGHTS
def test_resolve_weights_empty_profile_weights_fallback() -> None:
"""Профиль найден но weights пустой → системные дефолты."""
profile = _make_profile_mock({}) # пустые weights
db = MagicMock()
import app.services.site_finder.weight_profiles as wp_module
original_default = wp_module.get_default_profile
wp_module.get_default_profile = lambda _db, uid: profile
try:
result = resolve_weights(db, user_id="user-1", profile_id=None)
finally:
wp_module.get_default_profile = original_default
assert result == _SYSTEM_POI_WEIGHTS