feat(tradein/v2): реальный display-профиль в TopNav через /me (#2046)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m37s
CI Trade-In / backend-tests (pull_request) Successful in 2m0s

/me теперь отдаёт display_name/org/email (kopylov -> "Копылов", остальные None).
TopNav использует их вместо фабрикации username-as-name; org/email фолбэк
остаётся прежним для юзеров без известного профиля.
This commit is contained in:
bot-backend 2026-07-03 22:55:12 +03:00
parent e9e3cc7dcf
commit dafd12d9fa
4 changed files with 89 additions and 8 deletions

View file

@ -44,6 +44,11 @@ class UserScope(TypedDict):
# #657 white-label: brand slug, привязанный к аккаунту. None = generic UI.
# Фронт (useBrand.ts) применяет бренд автоматически на login (без ?brand=).
brand: str | None
# #2046 real profile fields для TopNav (фамилия/орг/email). None = фронт
# фолбэкается на username/role (как раньше) — фолбэк остаётся на фронте.
display_name: str | None
org: str | None
email: str | None
# ---------------------------------------------------------------------------
@ -65,6 +70,26 @@ def get_brand_for_user(username: str) -> str | None:
return _USERNAME_BRAND.get(username)
# ---------------------------------------------------------------------------
# Account → profile mapping (#2046 real profile fields for TopNav)
# ---------------------------------------------------------------------------
#
# Реальные имя/организация/email пилотов для TopNav-меню (замена фабрикованного
# "username as name"). Известные данные — только display_name для kopylov
# (фамилия «Копылов» из комментария в caddy/users.caddy.snippet). org/email для
# него не задокументированы нигде — умышленно НЕ выдумываем, оставляем None,
# фронт держит текущий фолбэк (username / brand ?? role / "").
_USERNAME_PROFILE: dict[str, dict[str, str]] = {
"kopylov": {"display_name": "Копылов"},
}
def get_profile_for_user(username: str) -> tuple[str | None, str | None, str | None]:
"""Return (display_name, org, email) bound to *username*, or all-None."""
profile = _USERNAME_PROFILE.get(username, {})
return profile.get("display_name"), profile.get("org"), profile.get("email")
# ---------------------------------------------------------------------------
# YAML loading
# ---------------------------------------------------------------------------
@ -193,12 +218,16 @@ def get_user_scope(username: str) -> UserScope:
config = _load_roles_config()
role = get_role(username)
role_def = config["roles"][role]
display_name, org, email = get_profile_for_user(username)
return UserScope(
username=username,
role=role,
allowed_paths=list(role_def.get("paths", []) or []),
deny_paths=list(role_def.get("deny", []) or []),
brand=get_brand_for_user(username),
display_name=display_name,
org=org,
email=email,
)

View file

@ -23,7 +23,7 @@ sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.core.auth import get_brand_for_user, get_user_scope # noqa: E402
from app.core.auth import get_brand_for_user, get_profile_for_user, get_user_scope # noqa: E402
@pytest.mark.parametrize(
@ -59,3 +59,41 @@ def test_user_scope_brand_none_for_praktika() -> None:
def test_user_scope_brand_none_for_generic_user() -> None:
scope = get_user_scope("user1")
assert scope["brand"] is None
# ---------------------------------------------------------------------------
# #2046 profile fields (display_name/org/email) — TopNav real identity.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("username", "expected"),
[
# Единственный известный display_name — фамилия Копылова (см. комментарий
# в caddy/users.caddy.snippet). org/email для него не задокументированы —
# намеренно None, не выдумываем.
("kopylov", ("Копылов", None, None)),
("praktika", (None, None, None)),
("admin", (None, None, None)),
("user1", (None, None, None)),
("unknown_user", (None, None, None)),
],
)
def test_get_profile_for_user(
username: str, expected: tuple[str | None, str | None, str | None]
) -> None:
assert get_profile_for_user(username) == expected
def test_user_scope_display_name_kopylov() -> None:
scope = get_user_scope("kopylov")
assert scope["display_name"] == "Копылов"
assert scope["org"] is None
assert scope["email"] is None
def test_user_scope_profile_none_for_generic_user() -> None:
scope = get_user_scope("user1")
assert scope["display_name"] is None
assert scope["org"] is None
assert scope["email"] is None

View file

@ -439,9 +439,9 @@ export default function TradeInV2Page() {
const history = useEstimateHistory();
const quota = useQuota();
// Real logged-in identity for the TopNav user menu (replaces the design
// fixture). /me returns {username, role, brand} — no display name / email, so
// name falls back to username, org to brand ?? role, email to "" (renders
// blank, never invented). undefined while loading → TopNav «Гость» fallback.
// fixture). /me returns {username, role, brand, display_name, org, email}
// (#2046) — known profile fields fall back to username / brand ?? role / ""
// when absent (never invented). undefined while loading → TopNav «Гость».
const me = useMe();
// Dashboard sub-hooks — each resolves independently; failure degrades its
@ -557,14 +557,23 @@ export default function TradeInV2Page() {
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows
// its neutral «Гость» fallback (never the old "Андрей Петров / Брусника").
// #2046: display_name/org/email — реальный профиль, если backend его знает
// (см. app.core.auth._USERNAME_PROFILE); иначе фолбэк на username/brand?role/"".
const topNavUser = useMemo(() => {
const scope = me.data;
if (!scope) return undefined;
const nameParts = scope.display_name?.trim().split(/\s+/).filter(Boolean) ?? [];
const initials =
nameParts.length >= 2
? (nameParts[0][0] + nameParts[1][0]).toUpperCase()
: nameParts.length === 1
? nameParts[0].slice(0, 2).toUpperCase()
: scope.username.slice(0, 2).toUpperCase();
return {
name: scope.username,
org: scope.brand ?? scope.role,
email: "",
initials: scope.username.slice(0, 2).toUpperCase(),
name: scope.display_name ?? scope.username,
org: scope.org ?? scope.brand ?? scope.role,
email: scope.email ?? "",
initials,
};
}, [me.data]);

View file

@ -24,6 +24,11 @@ export interface UserScope {
// #657 brand-by-account: slug бренда, привязанный к аккаунту (praktika→"praktika").
// null/undefined = generic UI. useBrand применяет его автоматически на login.
brand?: string | null;
// #2046 real profile fields для TopNav. undefined/null = данных нет, фронт
// фолбэкается на username / brand ?? role / "" (как раньше).
display_name?: string | null;
org?: string | null;
email?: string | null;
}
export const ME_QUERY_KEY = ["auth", "me"] as const;