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:
2026-07-17 02:22:27 +03:00
co-authored by Claude Fable 5
parent fc1b1fd912
commit e5b7420bae
5 changed files with 178 additions and 0 deletions
+19
View File
@@ -173,3 +173,22 @@ def nickname_available(
) -> s.NicknameAvailable:
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
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
]
+8
View File
@@ -112,6 +112,14 @@ class NicknameAvailable(BaseModel):
available: bool
class UserSuggestion(BaseModel):
"""Подсказка автокомплита по нику (приглашение в группу и т.п.)."""
user_id: int
nickname: str
avatar_url: str | None = None
# ─── Группы и членство ───────────────────────────────────────────────────────
class GroupCreate(BaseModel):
+22
View File
@@ -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
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:
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
return bool(_NICK_RE.match((nickname or "").strip()))
+65
View File
@@ -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
)