Compare commits
3 Commits
25ffd9e678
...
v0.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 413789ba22 | |||
| c4721fcf99 | |||
| 09215da22a |
@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.7
|
VIDCONF_VERSION=0.0.8
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `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/),
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
проект придерживается [семантического версионирования](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
|
## [0.0.7] — 2026-07-27
|
||||||
|
|
||||||
Настройки устройств: выбор аудиовывода и компактная шторка на мобильном.
|
Настройки устройств: выбор аудиовывода и компактная шторка на мобильном.
|
||||||
@@ -183,6 +206,7 @@
|
|||||||
|
|
||||||
Первоначальная версия VidConf.
|
Первоначальная версия 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.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.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
|
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
@@ -174,12 +174,16 @@ async def send_conference_invitations(
|
|||||||
async def list_users(
|
async def list_users(
|
||||||
admin: Annotated[User, Depends(require_admin)],
|
admin: Annotated[User, Depends(require_admin)],
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
status: Annotated[Literal["active", "blocked"] | None, Query()] = None,
|
||||||
q: Annotated[str | None, Query()] = None,
|
q: Annotated[str | None, Query()] = None,
|
||||||
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
|
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
) -> AdminUserListOut:
|
) -> AdminUserListOut:
|
||||||
"""Список всех пользователей инстанса с текстовым поиском по email/имени."""
|
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
|
||||||
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset)
|
параметра — все) и текстовый поиск по email/имени/названию команды."""
|
||||||
|
rows, total = await AdminUserRepository(session).list_paginated(
|
||||||
|
status=status, q=q, limit=limit, offset=offset
|
||||||
|
)
|
||||||
media_root = _media_root()
|
media_root = _media_root()
|
||||||
items = [
|
items = [
|
||||||
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
|
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import ColumnElement, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.conference import Conference
|
from models.conference import Conference
|
||||||
@@ -56,25 +56,37 @@ class AdminConferenceRepository:
|
|||||||
|
|
||||||
|
|
||||||
class AdminUserRepository:
|
class AdminUserRepository:
|
||||||
"""Постраничный список пользователей с текстовым поиском по email/имени."""
|
"""Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
|
||||||
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
|
|
||||||
async def list_paginated(
|
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]:
|
) -> tuple[list[tuple[User, str | None]], int]:
|
||||||
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
|
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
|
||||||
|
|
||||||
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице
|
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
|
||||||
админки, у пользователя без команды — `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:
|
if q:
|
||||||
like = f"%{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 = (
|
items_stmt = (
|
||||||
select(User, Team.name)
|
select(User, Team.name)
|
||||||
.outerjoin(Team, User.team_id == Team.id)
|
.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)
|
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(
|
async def test_patch_user_role_and_block(
|
||||||
client: httpx.AsyncClient, db_session: AsyncSession
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ services:
|
|||||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.7}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.8}
|
||||||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||||||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export type AdminUserDetailOut = AdminUserOut
|
|||||||
|
|
||||||
/** Параметры выборки списка пользователей. */
|
/** Параметры выборки списка пользователей. */
|
||||||
export interface AdminUserQuery {
|
export interface AdminUserQuery {
|
||||||
|
/** Фильтр по блокировке — без параметра отдаются все пользователи. */
|
||||||
|
status?: 'active' | 'blocked'
|
||||||
q?: string
|
q?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
@@ -221,7 +223,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]):
|
|||||||
|
|
||||||
/** Список пользователей для админки — с поиском и пагинацией. */
|
/** Список пользователей для админки — с поиском и пагинацией. */
|
||||||
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
|
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}`)
|
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ const PAGE_SIZE = 10
|
|||||||
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
|
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
|
||||||
const TEAMS_LIMIT = 200
|
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,
|
* роли, блокировкой и командой (design/mockups/admin.html,
|
||||||
@@ -28,6 +34,7 @@ const TEAMS_LIMIT = 200
|
|||||||
*/
|
*/
|
||||||
export function AdminUsersTab() {
|
export function AdminUsersTab() {
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('all')
|
||||||
const [searchInput, setSearchInput] = useState('')
|
const [searchInput, setSearchInput] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [offset, setOffset] = useState(0)
|
const [offset, setOffset] = useState(0)
|
||||||
@@ -45,8 +52,14 @@ export function AdminUsersTab() {
|
|||||||
}, [searchInput])
|
}, [searchInput])
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'users', search, offset],
|
queryKey: ['admin', 'users', statusFilter, search, offset],
|
||||||
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }),
|
queryFn: () =>
|
||||||
|
listAdminUsers({
|
||||||
|
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||||
|
q: search || undefined,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: teamsData } = useQuery({
|
const { data: teamsData } = useQuery({
|
||||||
@@ -89,13 +102,29 @@ export function AdminUsersTab() {
|
|||||||
|
|
||||||
return (
|
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-row">
|
||||||
<div className="toolbar-left">
|
<div className="toolbar-left">
|
||||||
<div className="search-wrap">
|
<div className="search-wrap">
|
||||||
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск по имени или email…"
|
placeholder="Поиск по имени, email или команде…"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user