API: поиск игроков по нику для автокомплита (GET /api/users/search)
До 10 подсказок по подстроке ника среди активных игроков (role=player). Регистронезависимо и для кириллицы: SQLite LIKE/lower сворачивают регистр только у ASCII, поэтому фильтрация/ранжирование в Python (casefold): префиксные совпадения раньше подстрочных, внутри — по алфавиту. Пустой q — пустой список. Тесты + регенерированный schema.d.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -173,3 +173,22 @@ def nickname_available(
|
|||||||
) -> s.NicknameAvailable:
|
) -> s.NicknameAvailable:
|
||||||
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
|
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
|
||||||
return s.NicknameAvailable(available=available)
|
return s.NicknameAvailable(available=available)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=list[s.UserSuggestion])
|
||||||
|
def search_users(
|
||||||
|
q: str = Query("", max_length=64),
|
||||||
|
limit: int = Query(3, ge=1, le=10),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> list[s.UserSuggestion]:
|
||||||
|
"""Автокомплит по нику. Пустой q — пустой список (не ошибка): удобнее при наборе."""
|
||||||
|
users = user_service.search_users(session, q, limit=limit)
|
||||||
|
return [
|
||||||
|
s.UserSuggestion(
|
||||||
|
user_id=u.id, # type: ignore[arg-type]
|
||||||
|
nickname=u.nickname,
|
||||||
|
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ class NicknameAvailable(BaseModel):
|
|||||||
available: bool
|
available: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserSuggestion(BaseModel):
|
||||||
|
"""Подсказка автокомплита по нику (приглашение в группу и т.п.)."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
nickname: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
class GroupCreate(BaseModel):
|
class GroupCreate(BaseModel):
|
||||||
|
|||||||
@@ -34,6 +34,28 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N
|
|||||||
return existing is None or existing.id == exclude_user_id
|
return existing is None or existing.id == exclude_user_id
|
||||||
|
|
||||||
|
|
||||||
|
def search_users(session: Session, query: str, limit: int = 3) -> list[User]:
|
||||||
|
"""Подсказки по нику: активные игроки (role='player'), без учёта регистра.
|
||||||
|
|
||||||
|
SQLite LIKE/lower() сворачивают регистр только для ASCII, а ники бывают
|
||||||
|
кириллицей — поэтому выбираем активных игроков и фильтруем/ранжируем в
|
||||||
|
Python (casefold). Пользователей десятки, полный проход дёшев; при росте
|
||||||
|
до тысяч — заменить на индексированную колонку nickname_lower.
|
||||||
|
Сначала префиксные совпадения, затем подстрочные, внутри — по алфавиту.
|
||||||
|
"""
|
||||||
|
q = (query or "").strip().casefold()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
users = session.exec(
|
||||||
|
select(User).where(User.role == "player", User.is_active == True) # noqa: E712
|
||||||
|
).all()
|
||||||
|
matched = [u for u in users if q in u.nickname.casefold()]
|
||||||
|
matched.sort(
|
||||||
|
key=lambda u: (0 if u.nickname.casefold().startswith(q) else 1, u.nickname.casefold())
|
||||||
|
)
|
||||||
|
return matched[:limit]
|
||||||
|
|
||||||
|
|
||||||
def nickname_format_ok(nickname: str) -> bool:
|
def nickname_format_ok(nickname: str) -> bool:
|
||||||
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
|
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
|
||||||
return bool(_NICK_RE.match((nickname or "").strip()))
|
return bool(_NICK_RE.match((nickname or "").strip()))
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Поиск игроков для автокомплита по нику: GET /api/users/search."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.models import User
|
||||||
|
from tests.conftest import login
|
||||||
|
|
||||||
|
|
||||||
|
def _add_user(engine, nickname: str, *, active: bool = True, role: str = "player") -> None:
|
||||||
|
"""Готовим данные напрямую в БД: нужны выключенные аккаунты и админы."""
|
||||||
|
with Session(engine) as s:
|
||||||
|
s.add(
|
||||||
|
User(
|
||||||
|
nickname=nickname,
|
||||||
|
role=role,
|
||||||
|
auth_provider="stub" if role == "player" else "local",
|
||||||
|
is_active=active,
|
||||||
|
# CHECK ck_users_admin_has_password: админ обязан иметь пароль.
|
||||||
|
password_hash="x" if role == "admin" else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_prefix_first_cyrillic_case_insensitive(client: TestClient, engine):
|
||||||
|
login(client, "Искатель")
|
||||||
|
for nick in ("Барсик", "барс", "Морбарс", "Zed"):
|
||||||
|
_add_user(engine, nick)
|
||||||
|
|
||||||
|
r = client.get("/api/users/search", params={"q": "БАР"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
got = r.json()
|
||||||
|
# Префиксные раньше подстрочных, внутри — по алфавиту; регистр кириллицы не важен.
|
||||||
|
assert [u["nickname"] for u in got] == ["барс", "Барсик", "Морбарс"]
|
||||||
|
assert {"user_id", "nickname", "avatar_url"} <= set(got[0].keys())
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_empty_query_returns_empty_list(client: TestClient):
|
||||||
|
login(client, "Искатель")
|
||||||
|
r = client.get("/api/users/search", params={"q": " "})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_skips_inactive_and_admins(client: TestClient, engine):
|
||||||
|
login(client, "Искатель")
|
||||||
|
_add_user(engine, "Спящий барсук", active=False)
|
||||||
|
_add_user(engine, "адм барс", role="admin")
|
||||||
|
|
||||||
|
r = client.get("/api/users/search", params={"q": "барс"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_respects_limit(client: TestClient, engine):
|
||||||
|
login(client, "Искатель")
|
||||||
|
for i in range(5):
|
||||||
|
_add_user(engine, f"Игрок {i}")
|
||||||
|
|
||||||
|
assert len(client.get("/api/users/search", params={"q": "игрок"}).json()) == 3
|
||||||
|
assert (
|
||||||
|
len(client.get("/api/users/search", params={"q": "игрок", "limit": 5}).json()) == 5
|
||||||
|
)
|
||||||
Vendored
+64
@@ -225,6 +225,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/users/search": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Search Users
|
||||||
|
* @description Автокомплит по нику. Пустой q — пустой список (не ошибка): удобнее при наборе.
|
||||||
|
*/
|
||||||
|
get: operations["search_users_api_users_search_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/groups": {
|
"/api/groups": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1975,6 +1995,18 @@ export interface components {
|
|||||||
/** Avatar Url */
|
/** Avatar Url */
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* UserSuggestion
|
||||||
|
* @description Подсказка автокомплита по нику (приглашение в группу и т.п.).
|
||||||
|
*/
|
||||||
|
UserSuggestion: {
|
||||||
|
/** User Id */
|
||||||
|
user_id: number;
|
||||||
|
/** Nickname */
|
||||||
|
nickname: string;
|
||||||
|
/** Avatar Url */
|
||||||
|
avatar_url?: string | null;
|
||||||
|
};
|
||||||
/** ValidationError */
|
/** ValidationError */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
/** Location */
|
/** Location */
|
||||||
@@ -2388,6 +2420,38 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
search_users_api_users_search_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserSuggestion"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
my_groups_api_groups_get: {
|
my_groups_api_groups_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Reference in New Issue
Block a user