Compare commits
3 Commits
25ffd9e678
...
413789ba22
| Author | SHA1 | Date | |
|---|---|---|---|
| 413789ba22 | |||
| c4721fcf99 | |||
| 09215da22a |
@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||
# установке/обновлении — руками менять не нужно.
|
||||
VIDCONF_VERSION=0.0.7
|
||||
VIDCONF_VERSION=0.0.8
|
||||
|
||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -3,6 +3,29 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||
|
||||
## [0.0.8] — 2026-07-27
|
||||
|
||||
Админка: несколько mail-доменов для регистрации, фильтр и поиск по команде в
|
||||
списке пользователей, исправлен баг сохранения настроек.
|
||||
|
||||
### Исправлено
|
||||
- Сохранение любой настройки инстанса блокировалось ошибкой «уровень AI
|
||||
недоступен», даже если AI-модуль (транскрибация/суммаризация) выключен и
|
||||
пользователь уровень не трогал — фронт отправлял в `PUT /admin/settings`
|
||||
весь набор полей целиком, включая уже сохранённый `ai_level`. Теперь
|
||||
доступность уровня проверяется только при его фактической смене; фронт
|
||||
отправляет в запросе лишь реально изменённые поля.
|
||||
|
||||
### Добавлено
|
||||
- «Эталон mail-домена» в настройках инстанса поддерживает несколько
|
||||
доменов вместо одного — регистрация проходит с email с любого домена из
|
||||
списка. Старое значение из БД (один домен) читается без миграции и
|
||||
переписывается в новый формат при первом же сохранении.
|
||||
- В списке пользователей админки — вкладки «Активные»/«Заблокированные»/
|
||||
«Все» перед полем поиска.
|
||||
- Поиск по пользователям учитывает и название команды — пользователи без
|
||||
команды по-прежнему остаются в общей выдаче.
|
||||
|
||||
## [0.0.7] — 2026-07-27
|
||||
|
||||
Настройки устройств: выбор аудиовывода и компактная шторка на мобильном.
|
||||
@@ -183,6 +206,7 @@
|
||||
|
||||
Первоначальная версия VidConf.
|
||||
|
||||
[0.0.8]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.8
|
||||
[0.0.7]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.7
|
||||
[0.0.6]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.6
|
||||
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import anyio
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
@@ -174,12 +174,16 @@ async def send_conference_invitations(
|
||||
async def list_users(
|
||||
admin: Annotated[User, Depends(require_admin)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
status: Annotated[Literal["active", "blocked"] | None, Query()] = None,
|
||||
q: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> AdminUserListOut:
|
||||
"""Список всех пользователей инстанса с текстовым поиском по email/имени."""
|
||||
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset)
|
||||
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
|
||||
параметра — все) и текстовый поиск по email/имени/названию команды."""
|
||||
rows, total = await AdminUserRepository(session).list_paginated(
|
||||
status=status, q=q, limit=limit, offset=offset
|
||||
)
|
||||
media_root = _media_root()
|
||||
items = [
|
||||
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import ColumnElement, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.conference import Conference
|
||||
@@ -56,25 +56,37 @@ class AdminConferenceRepository:
|
||||
|
||||
|
||||
class AdminUserRepository:
|
||||
"""Постраничный список пользователей с текстовым поиском по email/имени."""
|
||||
"""Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_paginated(
|
||||
self, *, q: str | None, limit: int, offset: int
|
||||
self, *, status: str | None, q: str | None, limit: int, offset: int
|
||||
) -> tuple[list[tuple[User, str | None]], int]:
|
||||
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
|
||||
|
||||
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице
|
||||
админки, у пользователя без команды — `None`.
|
||||
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
|
||||
без фильтра (все пользователи). `q` ищет по email/имени пользователя
|
||||
И по названию команды (`LEFT JOIN` на `teams` — нужен и для этого
|
||||
поиска, и для колонки «Команда» в таблице админки; `LEFT`, а не
|
||||
`INNER`, — пользователи без команды не должны пропадать из общей
|
||||
выдачи, только не совпадать с поиском по названию команды).
|
||||
"""
|
||||
filters = []
|
||||
filters: list[ColumnElement[bool]] = []
|
||||
if status == "active":
|
||||
filters.append(User.is_blocked.is_(False))
|
||||
elif status == "blocked":
|
||||
filters.append(User.is_blocked.is_(True))
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
filters.append(or_(User.email.ilike(like), User.name_user.ilike(like)))
|
||||
filters.append(
|
||||
or_(User.email.ilike(like), User.name_user.ilike(like), Team.name.ilike(like))
|
||||
)
|
||||
|
||||
count_stmt = select(func.count()).select_from(User)
|
||||
count_stmt = (
|
||||
select(func.count()).select_from(User).outerjoin(Team, User.team_id == Team.id)
|
||||
)
|
||||
items_stmt = (
|
||||
select(User, Team.name)
|
||||
.outerjoin(Team, User.team_id == Team.id)
|
||||
|
||||
@@ -381,6 +381,67 @@ async def test_list_users_returns_all(client: httpx.AsyncClient, db_session: Asy
|
||||
assert all("email_verified" in item for item in items)
|
||||
|
||||
|
||||
async def test_list_users_filters_by_status(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""`status=active`/`blocked` фильтрует по `is_blocked`; без параметра — все."""
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
active_user = await _make_user(db_session)
|
||||
blocked_user = await _make_user(db_session)
|
||||
blocked_user.is_blocked = True
|
||||
await db_session.commit()
|
||||
|
||||
active_response = await client.get(
|
||||
"/api/v1/admin/users", params={"status": "active"}, headers=_auth_headers(admin)
|
||||
)
|
||||
assert active_response.status_code == 200, active_response.text
|
||||
active_ids = [item["id"] for item in active_response.json()["items"]]
|
||||
assert str(active_user.id) in active_ids
|
||||
assert str(blocked_user.id) not in active_ids
|
||||
|
||||
blocked_response = await client.get(
|
||||
"/api/v1/admin/users", params={"status": "blocked"}, headers=_auth_headers(admin)
|
||||
)
|
||||
assert blocked_response.status_code == 200, blocked_response.text
|
||||
blocked_ids = [item["id"] for item in blocked_response.json()["items"]]
|
||||
assert str(blocked_user.id) in blocked_ids
|
||||
assert str(active_user.id) not in blocked_ids
|
||||
|
||||
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
|
||||
all_ids = [item["id"] for item in all_response.json()["items"]]
|
||||
assert str(active_user.id) in all_ids
|
||||
assert str(blocked_user.id) in all_ids
|
||||
|
||||
|
||||
async def test_list_users_search_matches_team_name(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""`q` находит пользователей по (части) названия их команды; пользователи
|
||||
без команды при этом не пропадают из общей (без поиска) выдачи."""
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
team = Team(name=f"Rocket-{suffix} Squad")
|
||||
db_session.add(team)
|
||||
await db_session.flush()
|
||||
teamed_user = await _make_user(db_session)
|
||||
teamed_user.team_id = team.id
|
||||
teamless_user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/users", params={"q": f"Rocket-{suffix}"}, headers=_auth_headers(admin)
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
ids = [item["id"] for item in response.json()["items"]]
|
||||
assert str(teamed_user.id) in ids
|
||||
assert str(teamless_user.id) not in ids
|
||||
assert str(admin.id) not in ids
|
||||
|
||||
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
|
||||
all_ids = [item["id"] for item in all_response.json()["items"]]
|
||||
assert str(teamless_user.id) in all_ids
|
||||
|
||||
|
||||
async def test_patch_user_role_and_block(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
|
||||
@@ -82,7 +82,7 @@ services:
|
||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.7}
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.8}
|
||||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||||
volumes:
|
||||
|
||||
@@ -137,6 +137,8 @@ export type AdminUserDetailOut = AdminUserOut
|
||||
|
||||
/** Параметры выборки списка пользователей. */
|
||||
export interface AdminUserQuery {
|
||||
/** Фильтр по блокировке — без параметра отдаются все пользователи. */
|
||||
status?: 'active' | 'blocked'
|
||||
q?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
@@ -221,7 +223,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]):
|
||||
|
||||
/** Список пользователей для админки — с поиском и пагинацией. */
|
||||
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
|
||||
const qs = toQueryString({ q: query.q, limit: query.limit, offset: query.offset })
|
||||
const qs = toQueryString({ status: query.status, q: query.q, limit: query.limit, offset: query.offset })
|
||||
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ const PAGE_SIZE = 10
|
||||
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
|
||||
const TEAMS_LIMIT = 200
|
||||
|
||||
const STATUS_FILTERS: { value: 'active' | 'blocked' | 'all'; label: string }[] = [
|
||||
{ value: 'active', label: 'Активные' },
|
||||
{ value: 'blocked', label: 'Заблокированные' },
|
||||
{ value: 'all', label: 'Все' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
|
||||
* роли, блокировкой и командой (design/mockups/admin.html,
|
||||
@@ -28,6 +34,7 @@ const TEAMS_LIMIT = 200
|
||||
*/
|
||||
export function AdminUsersTab() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('all')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
@@ -45,8 +52,14 @@ export function AdminUsersTab() {
|
||||
}, [searchInput])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'users', search, offset],
|
||||
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }),
|
||||
queryKey: ['admin', 'users', statusFilter, search, offset],
|
||||
queryFn: () =>
|
||||
listAdminUsers({
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
q: search || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
}),
|
||||
})
|
||||
|
||||
const { data: teamsData } = useQuery({
|
||||
@@ -89,13 +102,29 @@ export function AdminUsersTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-tabs-inline">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setStatusFilter(f.value)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="toolbar-row">
|
||||
<div className="toolbar-left">
|
||||
<div className="search-wrap">
|
||||
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по имени или email…"
|
||||
placeholder="Поиск по имени, email или команде…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user