diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index a7f20b5..424a769 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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 + ] diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index d602f12..a7c2152 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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): diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 77907e2..13d7916 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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())) diff --git a/backend/tests/test_user_search.py b/backend/tests/test_user_search.py new file mode 100644 index 0000000..25c9186 --- /dev/null +++ b/backend/tests/test_user_search.py @@ -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 + ) diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 8ec6f32..553e44e 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -225,6 +225,26 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -1975,6 +1995,18 @@ export interface components { /** Avatar Url */ avatar_url?: string | null; }; + /** + * UserSuggestion + * @description Подсказка автокомплита по нику (приглашение в группу и т.п.). + */ + UserSuggestion: { + /** User Id */ + user_id: number; + /** Nickname */ + nickname: string; + /** Avatar Url */ + avatar_url?: string | null; + }; /** ValidationError */ ValidationError: { /** 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: { parameters: { query?: never;