Compare commits
9 Commits
904db83450
...
v0.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 28b87a8c95 | |||
| b35209a775 | |||
| 84b06f78f1 | |||
| 74de51dbd8 | |||
| 09a3c6c806 | |||
| 86f1facf61 | |||
| c897f40363 | |||
| 66935b04f8 | |||
| 07e5aba9c9 |
@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.3
|
VIDCONF_VERSION=0.0.5
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -50,3 +50,7 @@ backend/media/
|
|||||||
|
|
||||||
# Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий
|
# Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий
|
||||||
.forcc/
|
.forcc/
|
||||||
|
|
||||||
|
# Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) —
|
||||||
|
# привязаны к конкретной машине, в репозиторий не идут.
|
||||||
|
.claude/
|
||||||
|
|||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -3,6 +3,48 @@
|
|||||||
Формат основан на [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.5] — 2026-07-27
|
||||||
|
|
||||||
|
Контактный адрес инстанса: настраиваемый адрес для ответов на письма
|
||||||
|
(уходят от `no-reply@`) и кнопка проверки почтовой конфигурации из
|
||||||
|
админки.
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- Настройка «Контактный адрес» в админке (вкладка «Настройки») — email
|
||||||
|
подставляется в заголовок `Reply-To` писем подтверждения регистрации,
|
||||||
|
приглашений на конференции и саммари; при выключенной настройке
|
||||||
|
`Reply-To` не проставляется, поведение как раньше.
|
||||||
|
- Кнопка «Отправить тестовое письмо» — синхронная проверка почтовой
|
||||||
|
конфигурации без регистрации фиктивного пользователя. Получатель по
|
||||||
|
умолчанию — email текущего администратора, можно указать другой.
|
||||||
|
Результат виден сразу: успех либо текст ошибки транспорта (сервер
|
||||||
|
недоступен/получатель отклонён), без логина и пароля SMTP в ответе.
|
||||||
|
|
||||||
|
## [0.0.4] — 2026-07-26
|
||||||
|
|
||||||
|
Календарь конференций: перестроен в раскладку «календарь во всю ширину плюс
|
||||||
|
модальные окна» — карточки событий разгружены, подробности вынесены во
|
||||||
|
всплывающее окно.
|
||||||
|
|
||||||
|
### Изменения
|
||||||
|
- Форма создания и редактирования конференции больше не занимает постоянную
|
||||||
|
колонку справа, а открывается в модальном окне — по кнопке «Новая
|
||||||
|
конференция», по карандашику на карточке события или из информационного
|
||||||
|
окна. Добавлен общий хук `useModalDismiss` (закрытие по Escape и возврат
|
||||||
|
фокуса на элемент, из которого окно открыли).
|
||||||
|
- Блок календаря занимает всю ширину страницы.
|
||||||
|
- Карточка события в сетке содержит только название (обрезается многоточием и
|
||||||
|
больше не вылезает за границы ячейки) и — у организатора — кнопку
|
||||||
|
редактирования. Время, номер и статус переехали в информационное окно.
|
||||||
|
- Клик по карточке события всегда открывает информационное окно, в том числе
|
||||||
|
для своих конференций: редактирование стало отдельным действием.
|
||||||
|
- Информационное окно вхождения дополнено статус-бейджем и кнопкой
|
||||||
|
«Редактировать» для организатора.
|
||||||
|
|
||||||
|
### Исправления
|
||||||
|
- Подпись периода в недельном и месячном виде больше не содержит «г.»
|
||||||
|
(форматирование через `Intl.DateTimeFormat` вместо локали FullCalendar).
|
||||||
|
|
||||||
## [0.0.3] — 2026-07-26
|
## [0.0.3] — 2026-07-26
|
||||||
|
|
||||||
Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах
|
Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах
|
||||||
@@ -87,6 +129,8 @@
|
|||||||
|
|
||||||
Первоначальная версия VidConf.
|
Первоначальная версия VidConf.
|
||||||
|
|
||||||
|
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
|
||||||
|
[0.0.4]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.4
|
||||||
[0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3
|
[0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3
|
||||||
[0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2
|
[0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2
|
||||||
[0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1
|
[0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ from schemas.admin import (
|
|||||||
TeamListOut,
|
TeamListOut,
|
||||||
TeamOut,
|
TeamOut,
|
||||||
TeamUpdateIn,
|
TeamUpdateIn,
|
||||||
|
TestEmailIn,
|
||||||
|
TestEmailOut,
|
||||||
)
|
)
|
||||||
from schemas.conferences import ConferenceUpdateIn
|
from schemas.conferences import ConferenceUpdateIn
|
||||||
from services.ai_levels import detect_ai_levels
|
from services.ai_levels import detect_ai_levels
|
||||||
@@ -56,9 +58,11 @@ from services.conferences import (
|
|||||||
InvalidConferenceStateError,
|
InvalidConferenceStateError,
|
||||||
NotConferenceOwnerError,
|
NotConferenceOwnerError,
|
||||||
)
|
)
|
||||||
|
from services.email import EmailSendError, create_email_backend
|
||||||
from services.instance_settings import (
|
from services.instance_settings import (
|
||||||
InstanceSettingsService,
|
InstanceSettingsService,
|
||||||
InvalidAiLevelError,
|
InvalidAiLevelError,
|
||||||
|
InvalidContactEmailError,
|
||||||
InvalidEmailDomainError,
|
InvalidEmailDomainError,
|
||||||
InvalidTimezoneError,
|
InvalidTimezoneError,
|
||||||
SettingsUpdateIn,
|
SettingsUpdateIn,
|
||||||
@@ -391,12 +395,59 @@ async def update_settings(
|
|||||||
service = InstanceSettingsService(session)
|
service = InstanceSettingsService(session)
|
||||||
try:
|
try:
|
||||||
cfg = await service.update(data)
|
cfg = await service.update(data)
|
||||||
except (InvalidAiLevelError, InvalidTimezoneError, InvalidEmailDomainError) as exc:
|
except (
|
||||||
|
InvalidAiLevelError,
|
||||||
|
InvalidTimezoneError,
|
||||||
|
InvalidEmailDomainError,
|
||||||
|
InvalidContactEmailError,
|
||||||
|
) as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
|
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
|
||||||
return _to_settings_out(cfg, transcription_queue_served=queue_served)
|
return _to_settings_out(cfg, transcription_queue_served=queue_served)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/test-email", response_model=TestEmailOut)
|
||||||
|
async def send_test_email(
|
||||||
|
data: TestEmailIn,
|
||||||
|
admin: Annotated[User, Depends(require_admin)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
) -> TestEmailOut:
|
||||||
|
"""Отправить тестовое письмо синхронно — проверка почтовой конфигурации без
|
||||||
|
регистрации фиктивного пользователя.
|
||||||
|
|
||||||
|
Получатель по умолчанию — email текущего администратора. Отправка идёт
|
||||||
|
напрямую из эндпоинта (не через Celery), чтобы результат был виден сразу.
|
||||||
|
Секреты SMTP (логин/пароль) в ответе не участвуют — только хост/порт,
|
||||||
|
и то лишь при `EMAIL_BACKEND=smtp` (см. `.env.example`).
|
||||||
|
"""
|
||||||
|
app_settings = get_app_settings()
|
||||||
|
recipient = data.to or admin.email
|
||||||
|
cfg = await InstanceSettingsService(session).get()
|
||||||
|
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
||||||
|
is_smtp = app_settings.email_backend == "smtp"
|
||||||
|
smtp_host = app_settings.smtp_host if is_smtp else None
|
||||||
|
smtp_port = app_settings.smtp_port if is_smtp else None
|
||||||
|
|
||||||
|
backend = create_email_backend(app_settings)
|
||||||
|
try:
|
||||||
|
await backend.send(
|
||||||
|
to=recipient,
|
||||||
|
subject="Тестовое письмо VidConf",
|
||||||
|
body="Это тестовое письмо для проверки почтовой конфигурации инстанса VidConf.",
|
||||||
|
reply_to=reply_to,
|
||||||
|
)
|
||||||
|
except EmailSendError as exc:
|
||||||
|
return TestEmailOut(
|
||||||
|
success=False, message=str(exc), smtp_host=smtp_host, smtp_port=smtp_port
|
||||||
|
)
|
||||||
|
return TestEmailOut(
|
||||||
|
success=True,
|
||||||
|
message=f"Письмо успешно отправлено на {recipient}",
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
smtp_port=smtp_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut:
|
def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut:
|
||||||
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
|
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
|
||||||
return SettingsOut(
|
return SettingsOut(
|
||||||
@@ -410,6 +461,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
|
|||||||
registration_team_choice=cfg.registration_team_choice,
|
registration_team_choice=cfg.registration_team_choice,
|
||||||
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
|
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
|
||||||
registration_email_domain=cfg.registration_email_domain,
|
registration_email_domain=cfg.registration_email_domain,
|
||||||
|
contact_email_enabled=cfg.contact_email_enabled,
|
||||||
|
contact_email=cfg.contact_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,3 +82,8 @@ class InstanceConfig(BaseModel):
|
|||||||
# `services/instance_settings.py`.
|
# `services/instance_settings.py`.
|
||||||
registration_email_domain_enabled: bool = False
|
registration_email_domain_enabled: bool = False
|
||||||
registration_email_domain: str | None = None
|
registration_email_domain: str | None = None
|
||||||
|
# Контактный адрес инстанса — подставляется в `Reply-To` исходящих писем
|
||||||
|
# (сами письма уходят от `no-reply@`, отвечать на них некуда без этого
|
||||||
|
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
|
||||||
|
contact_email_enabled: bool = False
|
||||||
|
contact_email: str | None = None
|
||||||
|
|||||||
@@ -156,3 +156,28 @@ class SettingsOut(BaseModel):
|
|||||||
registration_team_choice: bool
|
registration_team_choice: bool
|
||||||
registration_email_domain_enabled: bool
|
registration_email_domain_enabled: bool
|
||||||
registration_email_domain: str | None = None
|
registration_email_domain: str | None = None
|
||||||
|
contact_email_enabled: bool
|
||||||
|
contact_email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailIn(BaseModel):
|
||||||
|
"""Тело запроса тестовой отправки (`POST /admin/settings/test-email`).
|
||||||
|
|
||||||
|
`to` не задан — получатель по умолчанию: email текущего администратора
|
||||||
|
(см. `api/admin.py::send_test_email`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
to: EmailStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailOut(BaseModel):
|
||||||
|
"""Результат тестовой отправки — понятный админу успех/ошибка транспорта.
|
||||||
|
|
||||||
|
`smtp_host`/`smtp_port` — только хост и порт (без логина/пароля, они не
|
||||||
|
покидают `.env`), заполняются лишь при `EMAIL_BACKEND=smtp`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int | None = None
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ class AuthService:
|
|||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
)
|
)
|
||||||
await self._issue_verification_email(user)
|
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
||||||
|
await self._issue_verification_email(user, reply_to=reply_to)
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -216,7 +217,7 @@ class AuthService:
|
|||||||
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
|
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
|
||||||
return TokenPair(access_token=access_token, refresh_token=refresh_token)
|
return TokenPair(access_token=access_token, refresh_token=refresh_token)
|
||||||
|
|
||||||
async def _issue_verification_email(self, user: User) -> None:
|
async def _issue_verification_email(self, user: User, *, reply_to: str | None = None) -> None:
|
||||||
token = secrets.token_urlsafe(32) # 256 бит случайности
|
token = secrets.token_urlsafe(32) # 256 бит случайности
|
||||||
expires_at = datetime.now(UTC) + timedelta(
|
expires_at = datetime.now(UTC) + timedelta(
|
||||||
hours=self._settings.email_verification_ttl_hours
|
hours=self._settings.email_verification_ttl_hours
|
||||||
@@ -231,6 +232,7 @@ class AuthService:
|
|||||||
to=user.email,
|
to=user.email,
|
||||||
subject="Подтверждение регистрации VidConf",
|
subject="Подтверждение регистрации VidConf",
|
||||||
body=f"Для подтверждения email перейдите по ссылке: {link}",
|
body=f"Для подтверждения email перейдите по ссылке: {link}",
|
||||||
|
reply_to=reply_to,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,14 @@ class EmailBackend(Protocol):
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None = None,
|
html_body: str | None = None,
|
||||||
attachments: Sequence[EmailAttachment] = (),
|
attachments: Sequence[EmailAttachment] = (),
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива)."""
|
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива).
|
||||||
|
|
||||||
|
`reply_to` — необязательный контактный адрес инстанса (см.
|
||||||
|
`services/instance_settings.py::InstanceConfig.contact_email`), проставляется
|
||||||
|
заголовком `Reply-To`, если задан.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@@ -72,13 +78,15 @@ class ConsoleEmailBackend:
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None = None,
|
html_body: str | None = None,
|
||||||
attachments: Sequence[EmailAttachment] = (),
|
attachments: Sequence[EmailAttachment] = (),
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
|
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
|
||||||
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
|
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
|
||||||
logger.info(
|
logger.info(
|
||||||
"EMAIL to=%s subject=%s attachments=[%s]\n%s",
|
"EMAIL to=%s subject=%s reply_to=%s attachments=[%s]\n%s",
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
|
reply_to or "нет",
|
||||||
attachment_names,
|
attachment_names,
|
||||||
body,
|
body,
|
||||||
)
|
)
|
||||||
@@ -116,6 +124,7 @@ class SmtpEmailBackend:
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None = None,
|
html_body: str | None = None,
|
||||||
attachments: Sequence[EmailAttachment] = (),
|
attachments: Sequence[EmailAttachment] = (),
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
|
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
|
||||||
message = _build_message(
|
message = _build_message(
|
||||||
@@ -125,6 +134,7 @@ class SmtpEmailBackend:
|
|||||||
body=body,
|
body=body,
|
||||||
html_body=html_body,
|
html_body=html_body,
|
||||||
attachments=attachments,
|
attachments=attachments,
|
||||||
|
reply_to=reply_to,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await aiosmtplib.send(
|
await aiosmtplib.send(
|
||||||
@@ -165,12 +175,15 @@ def _build_message(
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None,
|
html_body: str | None,
|
||||||
attachments: Sequence[EmailAttachment],
|
attachments: Sequence[EmailAttachment],
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> EmailMessage:
|
) -> EmailMessage:
|
||||||
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
|
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
message["From"] = sender
|
message["From"] = sender
|
||||||
message["To"] = to
|
message["To"] = to
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
|
if reply_to:
|
||||||
|
message["Reply-To"] = reply_to
|
||||||
message.set_content(body)
|
message.set_content(body)
|
||||||
if html_body is not None:
|
if html_body is not None:
|
||||||
message.add_alternative(html_body, subtype="html")
|
message.add_alternative(html_body, subtype="html")
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||||||
`ai_level`, `summary_recipients`, `display_timezone`,
|
`ai_level`, `summary_recipients`, `display_timezone`,
|
||||||
`registration_team_choice`, `registration_email_domain`) — новая настройка
|
`registration_team_choice`, `registration_email_domain`, `contact_email`) —
|
||||||
не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
|
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
|
||||||
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
||||||
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
||||||
(например, при рестарте backend) не перетирает уже сделанные администратором
|
(например, при рестарте backend) не перетирает уже сделанные администратором
|
||||||
@@ -17,7 +17,8 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, EmailStr, TypeAdapter
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -45,6 +46,7 @@ _KEY_SUMMARY_RECIPIENTS = "summary_recipients"
|
|||||||
_KEY_DISPLAY_TIMEZONE = "display_timezone"
|
_KEY_DISPLAY_TIMEZONE = "display_timezone"
|
||||||
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
|
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
|
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
|
||||||
|
_KEY_CONTACT_EMAIL = "contact_email"
|
||||||
|
|
||||||
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
||||||
_KEY_CHAT,
|
_KEY_CHAT,
|
||||||
@@ -62,6 +64,7 @@ _DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
|
|||||||
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
|
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
|
||||||
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
|
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
|
||||||
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
|
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
|
||||||
|
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
|
||||||
|
|
||||||
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
||||||
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
|
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
|
||||||
@@ -70,6 +73,11 @@ _EMAIL_DOMAIN_PATTERN = re.compile(
|
|||||||
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
|
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Валидация формата контактного email — переиспользует тот же валидатор,
|
||||||
|
# что и `EmailStr` в pydantic-схемах (`schemas/admin.py` и др.), без
|
||||||
|
# отдельного регэкспа под адрес целиком.
|
||||||
|
_CONTACT_EMAIL_ADAPTER: TypeAdapter[str] = TypeAdapter(EmailStr)
|
||||||
|
|
||||||
|
|
||||||
class SettingsUpdateIn(BaseModel):
|
class SettingsUpdateIn(BaseModel):
|
||||||
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
|
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
|
||||||
@@ -88,6 +96,8 @@ class SettingsUpdateIn(BaseModel):
|
|||||||
registration_team_choice: bool | None = None
|
registration_team_choice: bool | None = None
|
||||||
registration_email_domain_enabled: bool | None = None
|
registration_email_domain_enabled: bool | None = None
|
||||||
registration_email_domain: str | None = None
|
registration_email_domain: str | None = None
|
||||||
|
contact_email_enabled: bool | None = None
|
||||||
|
contact_email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BootstrapOverrides(BaseModel):
|
class BootstrapOverrides(BaseModel):
|
||||||
@@ -136,6 +146,7 @@ def build_bootstrap_defaults(
|
|||||||
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
|
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
|
||||||
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
|
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
|
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
|
||||||
|
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
|
||||||
}
|
}
|
||||||
if overrides is None:
|
if overrides is None:
|
||||||
return defaults
|
return defaults
|
||||||
@@ -172,6 +183,15 @@ class InvalidEmailDomainError(ValueError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidContactEmailError(ValueError):
|
||||||
|
"""Некорректная настройка контактного адреса инстанса.
|
||||||
|
|
||||||
|
Поднимается при попытке включить контактный адрес без email
|
||||||
|
(`enabled=true` и пустой/отсутствующий email) либо при email, не
|
||||||
|
проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class InstanceSettingsService:
|
class InstanceSettingsService:
|
||||||
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
|
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
|
||||||
|
|
||||||
@@ -268,6 +288,28 @@ class InstanceSettingsService:
|
|||||||
cfg.registration_email_domain = domain
|
cfg.registration_email_domain = domain
|
||||||
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
|
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
|
||||||
|
|
||||||
|
if patch.contact_email_enabled is not None or patch.contact_email is not None:
|
||||||
|
contact_enabled = (
|
||||||
|
patch.contact_email_enabled
|
||||||
|
if patch.contact_email_enabled is not None
|
||||||
|
else cfg.contact_email_enabled
|
||||||
|
)
|
||||||
|
raw_contact_email = (
|
||||||
|
patch.contact_email if patch.contact_email is not None else cfg.contact_email
|
||||||
|
)
|
||||||
|
contact_email = (
|
||||||
|
_normalize_contact_email(raw_contact_email) if raw_contact_email else None
|
||||||
|
)
|
||||||
|
if contact_enabled and contact_email is None:
|
||||||
|
raise InvalidContactEmailError(
|
||||||
|
"нельзя включить контактный адрес без указания email"
|
||||||
|
)
|
||||||
|
cfg.contact_email_enabled = contact_enabled
|
||||||
|
cfg.contact_email = contact_email
|
||||||
|
await self._set(
|
||||||
|
_KEY_CONTACT_EMAIL, {"enabled": contact_enabled, "email": contact_email}
|
||||||
|
)
|
||||||
|
|
||||||
if patch.transcription_enabled is not None:
|
if patch.transcription_enabled is not None:
|
||||||
cfg.transcriber = cfg.transcriber.model_copy(
|
cfg.transcriber = cfg.transcriber.model_copy(
|
||||||
update={"enabled": patch.transcription_enabled}
|
update={"enabled": patch.transcription_enabled}
|
||||||
@@ -360,6 +402,16 @@ def _normalize_email_domain(domain: str) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_contact_email(email: str) -> str:
|
||||||
|
"""Нормализовать контактный email (strip, lower) и провалидировать формат."""
|
||||||
|
normalized = email.strip().lower()
|
||||||
|
try:
|
||||||
|
_CONTACT_EMAIL_ADAPTER.validate_python(normalized)
|
||||||
|
except PydanticValidationError as exc:
|
||||||
|
raise InvalidContactEmailError(f"некорректный email: {email!r}") from exc
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
||||||
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
|
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
|
||||||
|
|
||||||
@@ -387,4 +439,8 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
|||||||
registration_email_domain=rows.get(
|
registration_email_domain=rows.get(
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
|
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
|
||||||
).get("domain"),
|
).get("domain"),
|
||||||
|
contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
|
||||||
|
"enabled", False
|
||||||
|
),
|
||||||
|
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from models.team import Team
|
|||||||
from models.user import User
|
from models.user import User
|
||||||
from services import ai_levels
|
from services import ai_levels
|
||||||
from services.conference_ids import generate_number, generate_slug
|
from services.conference_ids import generate_number, generate_slug
|
||||||
|
from services.email import EmailSendError
|
||||||
|
|
||||||
FUTURE = datetime.now(UTC) + timedelta(days=3)
|
FUTURE = datetime.now(UTC) + timedelta(days=3)
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ async def test_all_admin_endpoints_forbidden_for_non_admin(
|
|||||||
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
|
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
|
||||||
("GET", "/api/v1/admin/settings", None),
|
("GET", "/api/v1/admin/settings", None),
|
||||||
("PUT", "/api/v1/admin/settings", {}),
|
("PUT", "/api/v1/admin/settings", {}),
|
||||||
|
("POST", "/api/v1/admin/settings/test-email", {}),
|
||||||
]
|
]
|
||||||
for method, path, body in requests:
|
for method, path, body in requests:
|
||||||
response = await client.request(method, path, json=body, headers=headers)
|
response = await client.request(method, path, json=body, headers=headers)
|
||||||
@@ -606,3 +608,153 @@ async def test_put_settings_unavailable_ai_level_returns_400(
|
|||||||
headers=_auth_headers(admin),
|
headers=_auth_headers(admin),
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_put_settings_contact_email_enable_and_persist(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"contact_email_enabled": True, "contact_email": "Contact@VidConf.RU"},
|
||||||
|
headers=_auth_headers(admin),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["contact_email_enabled"] is True
|
||||||
|
assert body["contact_email"] == "contact@vidconf.ru"
|
||||||
|
|
||||||
|
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
|
||||||
|
assert reloaded.json()["contact_email"] == "contact@vidconf.ru"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_put_settings_contact_email_invalid_returns_400(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"contact_email_enabled": True, "contact_email": "not an email"},
|
||||||
|
headers=_auth_headers(admin),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# --- Тестовое письмо ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_test_email_defaults_to_admin_email(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Без явного `to` тестовое письмо уходит на email текущего администратора."""
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
sent: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _FakeBackend:
|
||||||
|
async def send(self, **kwargs: object) -> None:
|
||||||
|
sent.update(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert sent["to"] == admin.email
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_test_email_to_explicit_recipient(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
sent: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _FakeBackend:
|
||||||
|
async def send(self, **kwargs: object) -> None:
|
||||||
|
sent.update(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/admin/settings/test-email",
|
||||||
|
json={"to": "other@example.com"},
|
||||||
|
headers=_auth_headers(admin),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert sent["to"] == "other@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_test_email_uses_contact_email_as_reply_to(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
await db_session.commit()
|
||||||
|
await client.put(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"contact_email_enabled": True, "contact_email": "contact@vidconf.example"},
|
||||||
|
headers=_auth_headers(admin),
|
||||||
|
)
|
||||||
|
|
||||||
|
sent: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _FakeBackend:
|
||||||
|
async def send(self, **kwargs: object) -> None:
|
||||||
|
sent.update(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert sent["reply_to"] == "contact@vidconf.example"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_test_email_reports_transport_failure_without_leaking_secrets(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Сбой транспорта — понятный текст ошибки; хост/порт видны, логин/пароль — нет."""
|
||||||
|
monkeypatch.setenv("EMAIL_BACKEND", "smtp")
|
||||||
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
||||||
|
monkeypatch.setenv("SMTP_PORT", "2525")
|
||||||
|
monkeypatch.setenv("SMTP_USERNAME", "secret-user")
|
||||||
|
monkeypatch.setenv("SMTP_PASSWORD", "super-secret-password")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
class _FailingBackend:
|
||||||
|
async def send(self, **kwargs: object) -> None:
|
||||||
|
raise EmailSendError("временный сбой SMTP: сервер недоступен", retryable=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FailingBackend())
|
||||||
|
try:
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert "сбой SMTP" in body["message"]
|
||||||
|
assert body["smtp_host"] == "smtp.example.com"
|
||||||
|
assert body["smtp_port"] == 2525
|
||||||
|
assert "secret-user" not in response.text
|
||||||
|
assert "super-secret-password" not in response.text
|
||||||
|
finally:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class _CapturingEmailBackend:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.sent: list[tuple[str, str, str]] = []
|
self.sent: list[tuple[str, str, str]] = []
|
||||||
|
self.reply_to: list[str | None] = []
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
self,
|
self,
|
||||||
@@ -57,8 +58,10 @@ class _CapturingEmailBackend:
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None = None,
|
html_body: str | None = None,
|
||||||
attachments: Sequence[EmailAttachment] = (),
|
attachments: Sequence[EmailAttachment] = (),
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.sent.append((to, subject, body))
|
self.sent.append((to, subject, body))
|
||||||
|
self.reply_to.append(reply_to)
|
||||||
|
|
||||||
|
|
||||||
def _extract_verification_token(email_body: str) -> str:
|
def _extract_verification_token(email_body: str) -> str:
|
||||||
@@ -432,3 +435,34 @@ async def test_register_any_domain_allowed_when_verification_disabled(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201, response.text
|
assert response.status_code == 201, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_sets_reply_to_when_contact_email_enabled(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||||
|
) -> None:
|
||||||
|
"""Контактный адрес инстанса включён — письмо подтверждения регистрации несёт `Reply-To`."""
|
||||||
|
await InstanceSettingsService(db_session).update(
|
||||||
|
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@vidconf.example")
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "dave@example.com", "name_user": "Dave", "password": "supersecret1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
assert email_backend.reply_to[-1] == "contact@vidconf.example"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_no_reply_to_when_contact_email_disabled(
|
||||||
|
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||||
|
) -> None:
|
||||||
|
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "erin@example.com", "name_user": "Erin", "password": "supersecret1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
assert email_backend.reply_to[-1] is None
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from services.instance_settings import (
|
|||||||
BootstrapOverrides,
|
BootstrapOverrides,
|
||||||
InstanceSettingsService,
|
InstanceSettingsService,
|
||||||
InvalidAiLevelError,
|
InvalidAiLevelError,
|
||||||
|
InvalidContactEmailError,
|
||||||
InvalidEmailDomainError,
|
InvalidEmailDomainError,
|
||||||
InvalidTimezoneError,
|
InvalidTimezoneError,
|
||||||
SettingsUpdateIn,
|
SettingsUpdateIn,
|
||||||
@@ -69,6 +70,7 @@ _MANAGED_KEYS = (
|
|||||||
"display_timezone",
|
"display_timezone",
|
||||||
"registration_team_choice",
|
"registration_team_choice",
|
||||||
"registration_email_domain",
|
"registration_email_domain",
|
||||||
|
"contact_email",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -123,6 +125,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
|||||||
"display_timezone",
|
"display_timezone",
|
||||||
"registration_team_choice",
|
"registration_team_choice",
|
||||||
"registration_email_domain",
|
"registration_email_domain",
|
||||||
|
"contact_email",
|
||||||
}
|
}
|
||||||
cfg = await service.get()
|
cfg = await service.get()
|
||||||
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
||||||
@@ -132,6 +135,8 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
|||||||
assert cfg.registration_team_choice is False
|
assert cfg.registration_team_choice is False
|
||||||
assert cfg.registration_email_domain_enabled is False
|
assert cfg.registration_email_domain_enabled is False
|
||||||
assert cfg.registration_email_domain is None
|
assert cfg.registration_email_domain is None
|
||||||
|
assert cfg.contact_email_enabled is False
|
||||||
|
assert cfg.contact_email is None
|
||||||
|
|
||||||
|
|
||||||
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
||||||
@@ -384,6 +389,68 @@ async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
|
|||||||
assert cfg.registration_email_domain == "acme.io"
|
assert cfg.registration_email_domain == "acme.io"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_contact_email_enable_without_email_rejected(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""Включение контактного адреса без email (ни в патче, ни ранее сохранённого) → 400."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidContactEmailError):
|
||||||
|
await service.update(SettingsUpdateIn(contact_email_enabled=True))
|
||||||
|
|
||||||
|
cfg = await service.get()
|
||||||
|
assert cfg.contact_email_enabled is False
|
||||||
|
assert cfg.contact_email is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_contact_email_rejects_invalid_format(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidContactEmailError):
|
||||||
|
await service.update(SettingsUpdateIn(contact_email="not an email"))
|
||||||
|
|
||||||
|
cfg = await service.get()
|
||||||
|
assert cfg.contact_email is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_contact_email_normalizes_input(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""` Contact@VidConf.RU ` нормализуется в `contact@vidconf.ru` (strip, lower)."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
|
||||||
|
cfg = await service.update(
|
||||||
|
SettingsUpdateIn(contact_email_enabled=True, contact_email=" Contact@VidConf.RU ")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cfg.contact_email_enabled is True
|
||||||
|
assert cfg.contact_email == "contact@vidconf.ru"
|
||||||
|
|
||||||
|
reloaded = await service.get()
|
||||||
|
assert reloaded.contact_email == "contact@vidconf.ru"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_contact_email_can_be_disabled_keeping_stored_email(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""Выключение контактного адреса без передачи email не требует его и не роняет валидацию."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
await service.update(
|
||||||
|
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@acme.io")
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = await service.update(SettingsUpdateIn(contact_email_enabled=False))
|
||||||
|
|
||||||
|
assert cfg.contact_email_enabled is False
|
||||||
|
assert cfg.contact_email == "contact@acme.io"
|
||||||
|
|
||||||
|
|
||||||
async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer(
|
async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer(
|
||||||
db_session: AsyncSession, clean_instance_settings: None
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from celery.exceptions import MaxRetriesExceededError
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from core.db import engine
|
from core.db import engine
|
||||||
|
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
|
||||||
from services.conference_ids import generate_number, generate_slug
|
from services.conference_ids import generate_number, generate_slug
|
||||||
from services.email import EmailAttachment, EmailSendError
|
from services.email import EmailAttachment, EmailSendError
|
||||||
from workers.tasks import invitations as invitations_module
|
from workers.tasks import invitations as invitations_module
|
||||||
@@ -65,10 +66,13 @@ class _FakeEmailBackend:
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None = None,
|
html_body: str | None = None,
|
||||||
attachments: tuple[EmailAttachment, ...] = (),
|
attachments: tuple[EmailAttachment, ...] = (),
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if to in self._fail_for:
|
if to in self._fail_for:
|
||||||
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
|
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
|
||||||
self.sent.append({"to": to, "subject": subject, "attachments": attachments})
|
self.sent.append(
|
||||||
|
{"to": to, "subject": subject, "attachments": attachments, "reply_to": reply_to}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _Fixture:
|
class _Fixture:
|
||||||
@@ -364,6 +368,48 @@ async def test_pinned_conference_sends_to_owner_and_past_participants_with_email
|
|||||||
assert delivered == sent_to
|
assert delivered == sent_to
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(
|
||||||
|
*, contact_email_enabled: bool = False, contact_email: str | None = None
|
||||||
|
) -> InstanceConfig:
|
||||||
|
return InstanceConfig(
|
||||||
|
transcriber=TranscriberConfig(),
|
||||||
|
summarizer=SummarizerConfig(),
|
||||||
|
chat=ChatConfig(),
|
||||||
|
contact_email_enabled=contact_email_enabled,
|
||||||
|
contact_email=contact_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invitations_set_reply_to_when_contact_email_enabled(
|
||||||
|
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Включённый контактный адрес инстанса — приглашение несёт `Reply-To`."""
|
||||||
|
mock_backend = _FakeEmailBackend()
|
||||||
|
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
|
||||||
|
|
||||||
|
await send_invitations_async(
|
||||||
|
_FakeTask(),
|
||||||
|
fx.conference_id,
|
||||||
|
plugins_config=_cfg(contact_email_enabled=True, contact_email="contact@vidconf.example"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mock_backend.sent
|
||||||
|
assert all(call["reply_to"] == "contact@vidconf.example" for call in mock_backend.sent)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invitations_no_reply_to_when_contact_email_disabled(
|
||||||
|
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
|
||||||
|
mock_backend = _FakeEmailBackend()
|
||||||
|
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
|
||||||
|
|
||||||
|
await send_invitations_async(_FakeTask(), fx.conference_id, plugins_config=_cfg())
|
||||||
|
|
||||||
|
assert mock_backend.sent
|
||||||
|
assert all(call["reply_to"] is None for call in mock_backend.sent)
|
||||||
|
|
||||||
|
|
||||||
async def test_explicit_emails_override_default_recipients(
|
async def test_explicit_emails_override_default_recipients(
|
||||||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class _FakeEmailBackend:
|
|||||||
|
|
||||||
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
|
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
|
||||||
self.sent: list[str] = []
|
self.sent: list[str] = []
|
||||||
|
self.reply_to: list[str | None] = []
|
||||||
self._fail_for = fail_for or set()
|
self._fail_for = fail_for or set()
|
||||||
self._retryable = retryable
|
self._retryable = retryable
|
||||||
|
|
||||||
@@ -71,10 +72,12 @@ class _FakeEmailBackend:
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None = None,
|
html_body: str | None = None,
|
||||||
attachments: Any = (),
|
attachments: Any = (),
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if to in self._fail_for:
|
if to in self._fail_for:
|
||||||
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
|
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
|
||||||
self.sent.append(to)
|
self.sent.append(to)
|
||||||
|
self.reply_to.append(reply_to)
|
||||||
|
|
||||||
|
|
||||||
class _Fixture:
|
class _Fixture:
|
||||||
@@ -221,12 +224,19 @@ async def fx() -> AsyncGenerator[_Fixture, None]:
|
|||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _cfg(*, summary_recipients: str = "all") -> InstanceConfig:
|
def _cfg(
|
||||||
|
*,
|
||||||
|
summary_recipients: str = "all",
|
||||||
|
contact_email_enabled: bool = False,
|
||||||
|
contact_email: str | None = None,
|
||||||
|
) -> InstanceConfig:
|
||||||
return InstanceConfig(
|
return InstanceConfig(
|
||||||
transcriber=TranscriberConfig(),
|
transcriber=TranscriberConfig(),
|
||||||
summarizer=SummarizerConfig(),
|
summarizer=SummarizerConfig(),
|
||||||
chat=ChatConfig(),
|
chat=ChatConfig(),
|
||||||
summary_recipients=cast("Any", summary_recipients),
|
summary_recipients=cast("Any", summary_recipients),
|
||||||
|
contact_email_enabled=contact_email_enabled,
|
||||||
|
contact_email=contact_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -291,6 +301,40 @@ async def test_notify_session_mode_owner_sends_only_to_owner(
|
|||||||
assert await _fetch_session_status(fx.session_id) == "notified"
|
assert await _fetch_session_status(fx.session_id) == "notified"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_notify_session_sets_reply_to_when_contact_email_enabled(
|
||||||
|
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Включённый контактный адрес инстанса — саммари-письмо несёт `Reply-To`."""
|
||||||
|
backend = _FakeEmailBackend()
|
||||||
|
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
|
||||||
|
|
||||||
|
await notify_session_async(
|
||||||
|
_FakeTask(),
|
||||||
|
fx.session_id,
|
||||||
|
plugins_config=_cfg(
|
||||||
|
summary_recipients="owner",
|
||||||
|
contact_email_enabled=True,
|
||||||
|
contact_email="contact@vidconf.example",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert backend.reply_to == ["contact@vidconf.example"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_notify_session_no_reply_to_when_contact_email_disabled(
|
||||||
|
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
|
||||||
|
backend = _FakeEmailBackend()
|
||||||
|
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
|
||||||
|
|
||||||
|
await notify_session_async(
|
||||||
|
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert backend.reply_to == [None]
|
||||||
|
|
||||||
|
|
||||||
async def test_notify_session_conference_override_wins_over_instance_default(
|
async def test_notify_session_conference_override_wins_over_instance_default(
|
||||||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import aiosmtplib
|
import aiosmtplib
|
||||||
import pytest
|
import pytest
|
||||||
@@ -90,6 +92,33 @@ def test_build_message_includes_html_alternative_and_ics_attachment() -> None:
|
|||||||
assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
|
assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_message_sets_reply_to_when_given() -> None:
|
||||||
|
message = _build_message(
|
||||||
|
sender="VidConf <no-reply@vidconf.example>",
|
||||||
|
to="user@example.com",
|
||||||
|
subject="Тема",
|
||||||
|
body="Тело",
|
||||||
|
html_body=None,
|
||||||
|
attachments=(),
|
||||||
|
reply_to="contact@vidconf.example",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert message["Reply-To"] == "contact@vidconf.example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_message_omits_reply_to_when_not_given() -> None:
|
||||||
|
message = _build_message(
|
||||||
|
sender="VidConf <no-reply@vidconf.example>",
|
||||||
|
to="user@example.com",
|
||||||
|
subject="Тема",
|
||||||
|
body="Тело",
|
||||||
|
html_body=None,
|
||||||
|
attachments=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert message["Reply-To"] is None
|
||||||
|
|
||||||
|
|
||||||
def _backend() -> SmtpEmailBackend:
|
def _backend() -> SmtpEmailBackend:
|
||||||
return SmtpEmailBackend(
|
return SmtpEmailBackend(
|
||||||
hostname="smtp.example.com",
|
hostname="smtp.example.com",
|
||||||
@@ -168,3 +197,23 @@ async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.Mo
|
|||||||
"use_tls": False,
|
"use_tls": False,
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_smtp_backend_send_passes_reply_to_into_message(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]:
|
||||||
|
captured["message"] = message
|
||||||
|
return {}, "OK"
|
||||||
|
|
||||||
|
monkeypatch.setattr(aiosmtplib, "send", _fake_send)
|
||||||
|
backend = _backend()
|
||||||
|
|
||||||
|
await backend.send(
|
||||||
|
to="user@example.com", subject="Тема", body="Тело", reply_to="contact@vidconf.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
message = cast(EmailMessage, captured["message"])
|
||||||
|
assert message["Reply-To"] == "contact@vidconf.example"
|
||||||
|
|||||||
@@ -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.3}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.5}
|
||||||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||||||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export interface SettingsOut {
|
|||||||
registration_email_domain_enabled: boolean
|
registration_email_domain_enabled: boolean
|
||||||
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
|
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
|
||||||
registration_email_domain: string | null
|
registration_email_domain: string | null
|
||||||
|
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
|
||||||
|
contact_email_enabled: boolean
|
||||||
|
/** Контактный адрес — `null`, если не задан/выключен. */
|
||||||
|
contact_email: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
||||||
@@ -48,6 +52,24 @@ export interface SettingsUpdateIn {
|
|||||||
/** Включение без домена или невалидный домен — backend отвечает 400. */
|
/** Включение без домена или невалидный домен — backend отвечает 400. */
|
||||||
registration_email_domain_enabled?: boolean
|
registration_email_domain_enabled?: boolean
|
||||||
registration_email_domain?: string | null
|
registration_email_domain?: string | null
|
||||||
|
/** Включение без email или невалидный email — backend отвечает 400. */
|
||||||
|
contact_email_enabled?: boolean
|
||||||
|
contact_email?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
|
||||||
|
export interface TestEmailIn {
|
||||||
|
/** Не задан — backend отправит на email текущего администратора. */
|
||||||
|
to?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Результат тестовой отправки — успех или текст ошибки транспорта. */
|
||||||
|
export interface TestEmailOut {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
/** Хост/порт SMTP — только при `EMAIL_BACKEND=smtp`, без логина/пароля. */
|
||||||
|
smtp_host: string | null
|
||||||
|
smtp_port: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
|
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
|
||||||
@@ -238,6 +260,14 @@ export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<Se
|
|||||||
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
|
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить тестовое письмо синхронно — проверка почтовой конфигурации.
|
||||||
|
* Результат приходит в теле ответа (`success`/`message`), не через HTTP-статус ошибки.
|
||||||
|
*/
|
||||||
|
export async function sendTestEmail(payload: TestEmailIn = {}): Promise<TestEmailOut> {
|
||||||
|
return apiRequest<TestEmailOut>('/admin/settings/test-email', { method: 'POST', body: payload })
|
||||||
|
}
|
||||||
|
|
||||||
/** Список команд для админки — с пагинацией. */
|
/** Список команд для админки — с пагинацией. */
|
||||||
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
|
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
|
||||||
const qs = toQueryString({ limit: query.limit, offset: query.offset })
|
const qs = toQueryString({ limit: query.limit, offset: query.offset })
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin'
|
import {
|
||||||
|
getAdminSettings,
|
||||||
|
sendTestEmail,
|
||||||
|
updateAdminSettings,
|
||||||
|
type AiLevel,
|
||||||
|
type SettingsOut,
|
||||||
|
type SettingsUpdateIn,
|
||||||
|
type TestEmailOut,
|
||||||
|
} from '@/api/admin'
|
||||||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
import type { SummaryRecipientsMode } from '@/api/conferences'
|
||||||
import { ApiError, errorDetail } from '@/api/client'
|
import { ApiError, errorDetail } from '@/api/client'
|
||||||
|
import { useAuth } from '@/auth/useAuth'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
|
|
||||||
@@ -42,6 +51,7 @@ export function AdminSettingsTab() {
|
|||||||
function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
|
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
|
||||||
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
|
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
|
||||||
@@ -51,6 +61,10 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
|
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
|
||||||
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
|
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
|
||||||
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
|
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
|
||||||
|
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
||||||
|
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
|
||||||
|
const [testEmailTo, setTestEmailTo] = useState('')
|
||||||
|
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
|
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
|
||||||
@@ -67,6 +81,19 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const testEmailMutation = useMutation({
|
||||||
|
mutationFn: () => sendTestEmail(testEmailTo.trim() ? { to: testEmailTo.trim() } : {}),
|
||||||
|
onSuccess: (result) => setTestEmailResult(result),
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
setTestEmailResult({
|
||||||
|
success: false,
|
||||||
|
message: (err instanceof ApiError && errorDetail(err)) || 'Не удалось отправить тестовое письмо',
|
||||||
|
smtp_host: null,
|
||||||
|
smtp_port: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
chat_enabled: chatEnabled,
|
chat_enabled: chatEnabled,
|
||||||
@@ -77,6 +104,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
registration_team_choice: teamChoiceEnabled,
|
registration_team_choice: teamChoiceEnabled,
|
||||||
registration_email_domain_enabled: domainVerificationEnabled,
|
registration_email_domain_enabled: domainVerificationEnabled,
|
||||||
registration_email_domain: emailDomain.trim() || null,
|
registration_email_domain: emailDomain.trim() || null,
|
||||||
|
contact_email_enabled: contactEmailEnabled,
|
||||||
|
contact_email: contactEmail.trim() || null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +297,80 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-card">
|
||||||
|
<h2>Контактный адрес</h2>
|
||||||
|
<p className="desc">
|
||||||
|
Адрес для ответов на письма от инстанса (уходят от no-reply — этот адрес подставляется
|
||||||
|
в заголовок «Reply-To»).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="settings-card-body">
|
||||||
|
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||||
|
<div className="toggle-copy">
|
||||||
|
<strong>Контактный адрес включён</strong>
|
||||||
|
<span>Подставляется в Reply-To подтверждения регистрации, приглашений и саммари</span>
|
||||||
|
</div>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={contactEmailEnabled}
|
||||||
|
onChange={(e) => setContactEmailEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
|
||||||
|
<label htmlFor="settings-contact-email">Email</label>
|
||||||
|
<input
|
||||||
|
id="settings-contact-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="contact@vidconf.ru"
|
||||||
|
value={contactEmail}
|
||||||
|
disabled={!contactEmailEnabled}
|
||||||
|
onChange={(e) => setContactEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-card">
|
||||||
|
<h2>Тестовое письмо</h2>
|
||||||
|
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>
|
||||||
|
|
||||||
|
<div className="settings-card-body">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="settings-test-email-to">Получатель</label>
|
||||||
|
<input
|
||||||
|
id="settings-test-email-to"
|
||||||
|
type="email"
|
||||||
|
placeholder={user?.email ?? 'you@example.com'}
|
||||||
|
value={testEmailTo}
|
||||||
|
onChange={(e) => setTestEmailTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={testEmailMutation.isPending}
|
||||||
|
onClick={() => testEmailMutation.mutate()}
|
||||||
|
>
|
||||||
|
{testEmailMutation.isPending ? 'Отправляем…' : 'Отправить тестовое письмо'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{testEmailResult && (
|
||||||
|
<p
|
||||||
|
className="field-hint"
|
||||||
|
style={{ color: testEmailResult.success ? 'var(--color-success)' : 'var(--color-danger)' }}
|
||||||
|
>
|
||||||
|
{testEmailResult.message}
|
||||||
|
{testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="settings-actions">
|
<div className="settings-actions">
|
||||||
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
|
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
|
||||||
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
|
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core'
|
|||||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
import interactionPlugin from '@fullcalendar/interaction'
|
import interactionPlugin from '@fullcalendar/interaction'
|
||||||
import ruLocale from '@fullcalendar/core/locales/ru'
|
import ruLocale from '@fullcalendar/core/locales/ru'
|
||||||
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Lock, Play, Repeat } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, Lock, Pencil } from 'lucide-react'
|
||||||
import type { OccurrenceOut } from '@/api/conferences'
|
import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
|
||||||
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
|
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -35,6 +35,16 @@ const HIDDEN_DAYS_NONE: number[] = []
|
|||||||
*/
|
*/
|
||||||
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
|
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Актуальный колбэк редактирования из последнего рендера
|
||||||
|
* `ConferenceCalendar`. `renderEventContent` обязан оставаться одной и той же
|
||||||
|
* функцией на всё время жизни модуля (см. комментарий выше про
|
||||||
|
* resetOptions/бесконечный цикл), поэтому не может напрямую замкнуть пропс
|
||||||
|
* компонента — вместо этого читает его отсюда, а компонент синхронизирует
|
||||||
|
* переменную эффектом при каждом изменении пропса.
|
||||||
|
*/
|
||||||
|
let latestOnEditClick: (conference: ConferenceOut) => void = () => {}
|
||||||
|
|
||||||
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
|
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
|
||||||
|
|
||||||
interface ConferenceCalendarProps {
|
interface ConferenceCalendarProps {
|
||||||
@@ -42,74 +52,113 @@ interface ConferenceCalendarProps {
|
|||||||
onViewChange: (view: CalendarViewMode) => void
|
onViewChange: (view: CalendarViewMode) => void
|
||||||
occurrences: OccurrenceOut[]
|
occurrences: OccurrenceOut[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
/** Владельческие конференции текущего пользователя (`/conferences/my`, только свои — `is_owner`) — определяет, показывать ли карандашик на чипе. */
|
||||||
|
myConferenceById: Map<string, ConferenceOut>
|
||||||
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
|
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
|
||||||
onRangeChange: (fromIso: string, toIso: string) => void
|
onRangeChange: (fromIso: string, toIso: string) => void
|
||||||
onOccurrenceClick: (occurrence: OccurrenceOut) => void
|
onOccurrenceClick: (occurrence: OccurrenceOut) => void
|
||||||
|
onEditClick: (conference: ConferenceOut) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type BadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended'
|
/**
|
||||||
|
* Не зависит от пропсов/состояния компонента — модульная константа-функция
|
||||||
function occurrenceBadge(occ: OccurrenceOut, start: Date | null, end: Date | null): { kind: BadgeKind; label: string } {
|
* (см. комментарий выше про стабильность identity). В чипе — только название
|
||||||
const now = new Date()
|
* (обрезается многоточием по цепочке min-width:0/overflow:hidden ниже по
|
||||||
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' }
|
* дереву, см. calendar.css) и, у владельца, карандашик редактирования; время/
|
||||||
if (end && now > end) return { kind: 'ended', label: 'Завершена' }
|
* статус/номер — только в информационном окне (`ConferenceOccurrenceDialog`).
|
||||||
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' }
|
*/
|
||||||
return { kind: 'scheduled', label: 'Запланирована' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const BADGE_ICON: Record<BadgeKind, typeof Clock> = {
|
|
||||||
scheduled: Clock,
|
|
||||||
pinned: Repeat,
|
|
||||||
live: Play,
|
|
||||||
ended: CheckCircle2,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Не зависит от пропсов/состояния компонента — модульная константа-функция (см. комментарий выше про стабильность identity). */
|
|
||||||
function renderEventContent(arg: EventContentArg) {
|
function renderEventContent(arg: EventContentArg) {
|
||||||
const occ = arg.event.extendedProps.occurrence as OccurrenceOut
|
const occ = arg.event.extendedProps.occurrence as OccurrenceOut
|
||||||
const start = arg.event.start
|
const ownedConference = arg.event.extendedProps.ownedConference as ConferenceOut | undefined
|
||||||
const end = arg.event.end
|
|
||||||
const badge = occurrenceBadge(occ, start, end)
|
|
||||||
const Icon = BADGE_ICON[badge.kind]
|
|
||||||
const timeLabel =
|
|
||||||
start && end
|
|
||||||
? `${start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}–${end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
|
|
||||||
: ''
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="chip-row">
|
||||||
<span className={`conf-badge ${badge.kind}`}>
|
|
||||||
<Icon style={{ width: 11, height: 11 }} aria-hidden="true" />
|
|
||||||
{badge.label}
|
|
||||||
</span>
|
|
||||||
<span className="chip-time">{timeLabel}</span>
|
|
||||||
<span className="chip-title">
|
<span className="chip-title">
|
||||||
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
|
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
|
||||||
{arg.event.title}
|
<span className="chip-title-text">{arg.event.title}</span>
|
||||||
</span>
|
</span>
|
||||||
|
{ownedConference && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chip-edit"
|
||||||
|
aria-label="Редактировать конференцию"
|
||||||
|
onClick={(e) => {
|
||||||
|
// Иначе клик долетит и до обработчика чипа (eventClick) — тот
|
||||||
|
// открыл бы информационное окно вместо формы редактирования.
|
||||||
|
e.stopPropagation()
|
||||||
|
latestOnEditClick(ownedConference)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */
|
||||||
|
const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форма аргумента, который FullCalendar передаёт функции-`titleFormat`
|
||||||
|
* (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не
|
||||||
|
* реэкспортируется из публичного `@fullcalendar/core`, поэтому типизируем
|
||||||
|
* структурно сами, только нужные поля).
|
||||||
|
*/
|
||||||
|
interface TitleFormatArg {
|
||||||
|
start: { marker: Date }
|
||||||
|
end?: { marker: Date }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит titleFormat-функцию FullCalendar на Intl.DateTimeFormat вместо
|
||||||
|
* ruLocale-зависимого форматтера — тот всегда добавляет «г.» после года.
|
||||||
|
* FullCalendar передаёт границы диапазона уже с учётом exclusivity (конец
|
||||||
|
* периода минус 1мс, см. `dateEnv.formatRange` в @fullcalendar/core) — этого
|
||||||
|
* достаточно, чтобы Intl.formatRange сам корректно схлопнул одинаковые
|
||||||
|
* месяц/год и красиво отформатировал переход через месяц/год.
|
||||||
|
*/
|
||||||
|
function buildTitleFormatter(options: Intl.DateTimeFormatOptions) {
|
||||||
|
const formatter = new Intl.DateTimeFormat('ru-RU', { ...options, timeZone: 'UTC' })
|
||||||
|
return (arg: TitleFormatArg): string => {
|
||||||
|
const raw = arg.end ? formatter.formatRange(arg.start.marker, arg.end.marker) : formatter.format(arg.start.marker)
|
||||||
|
return raw.replace(YEAR_SUFFIX_RE, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CALENDAR_VIEWS = {
|
||||||
|
dayGridWeek: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long', day: 'numeric' }) },
|
||||||
|
dayGridMonth: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long' }) },
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
|
* Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
|
||||||
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
|
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
|
||||||
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
|
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
|
||||||
* События рендерятся как «чипы» (`.conf-chip`) со статус-бейджем §4.6
|
* События рендерятся как «чипы» (`.conf-chip`) — только название (+карандашик
|
||||||
* DESIGN_SYSTEM.md — вместо позиционирования по времени (timeGrid), т.к.
|
* у владельца), статус/время/номер — в информационном окне по клику
|
||||||
* менеджер конференций показывает список вхождений по дням, не занятость
|
* (`ConferenceOccurrenceDialog`) — вместо позиционирования по времени
|
||||||
* переговорной по часам (переговорных комнат в модели нет).
|
* (timeGrid), т.к. менеджер конференций показывает список вхождений по дням,
|
||||||
|
* не занятость переговорной по часам (переговорных комнат в модели нет).
|
||||||
*/
|
*/
|
||||||
export function ConferenceCalendar({
|
export function ConferenceCalendar({
|
||||||
view,
|
view,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
occurrences,
|
occurrences,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
myConferenceById,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
onOccurrenceClick,
|
onOccurrenceClick,
|
||||||
|
onEditClick,
|
||||||
}: ConferenceCalendarProps) {
|
}: ConferenceCalendarProps) {
|
||||||
const calendarRef = useRef<FullCalendar | null>(null)
|
const calendarRef = useRef<FullCalendar | null>(null)
|
||||||
const titleRef = useRef<HTMLSpanElement | null>(null)
|
const titleRef = useRef<HTMLSpanElement | null>(null)
|
||||||
|
|
||||||
|
// См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
|
||||||
|
// переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
|
||||||
|
useEffect(() => {
|
||||||
|
latestOnEditClick = onEditClick
|
||||||
|
}, [onEditClick])
|
||||||
|
|
||||||
// Ховер-карточка конференции — состояние держим внутри
|
// Ховер-карточка конференции — состояние держим внутри
|
||||||
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
|
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
|
||||||
// оставались стабильными по identity между рендерами (см. комментарий в
|
// оставались стабильными по identity между рендерами (см. комментарий в
|
||||||
@@ -159,20 +208,24 @@ export function ConferenceCalendar({
|
|||||||
calendarRef.current?.getApi().today()
|
calendarRef.current?.getApi().today()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Мемоизировано по `occurrences` — иначе на каждый рендер создавался бы
|
// Мемоизировано по `occurrences`/`myConferenceById` — иначе на каждый рендер
|
||||||
// новый массив-`events` той же длины/содержимого, и FullCalendar считал бы
|
// создавался бы новый массив-`events` той же длины/содержимого, и
|
||||||
// его «изменившейся опцией» (см. комментарий у модульных констант выше).
|
// FullCalendar считал бы его «изменившейся опцией» (см. комментарий у
|
||||||
|
// модульных констант выше).
|
||||||
const events: EventInput[] = useMemo(
|
const events: EventInput[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
occurrences.map((occ) => ({
|
occurrences.map((occ) => {
|
||||||
id: `${occ.conference_id}:${occ.starts_at}`,
|
const owned = myConferenceById.get(occ.conference_id)
|
||||||
title: occ.title ?? 'Конференция без названия',
|
return {
|
||||||
start: occ.starts_at,
|
id: `${occ.conference_id}:${occ.starts_at}`,
|
||||||
end: occ.ends_at,
|
title: occ.title ?? 'Конференция без названия',
|
||||||
classNames: ['conf-chip'],
|
start: occ.starts_at,
|
||||||
extendedProps: { occurrence: occ },
|
end: occ.ends_at,
|
||||||
})),
|
classNames: ['conf-chip'],
|
||||||
[occurrences],
|
extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined },
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[occurrences, myConferenceById],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleEventClick = useCallback(
|
const handleEventClick = useCallback(
|
||||||
@@ -228,6 +281,7 @@ export function ConferenceCalendar({
|
|||||||
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
|
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
|
||||||
height="auto"
|
height="auto"
|
||||||
dayMaxEvents={false}
|
dayMaxEvents={false}
|
||||||
|
views={CALENDAR_VIEWS}
|
||||||
events={events}
|
events={events}
|
||||||
eventContent={renderEventContent}
|
eventContent={renderEventContent}
|
||||||
eventClick={handleEventClick}
|
eventClick={handleEventClick}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Calendar, Lock, Play, Repeat, X } from 'lucide-react'
|
import { Calendar, Hash, Lock, Pencil, Play, Repeat, X } from 'lucide-react'
|
||||||
import type { OccurrenceOut } from '@/api/conferences'
|
import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
|
||||||
|
import { occurrenceBadge, OCCURRENCE_BADGE_ICON } from '@/lib/occurrenceStatus'
|
||||||
|
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||||
|
|
||||||
interface ConferenceOccurrenceDialogProps {
|
interface ConferenceOccurrenceDialogProps {
|
||||||
occurrence: OccurrenceOut
|
occurrence: OccurrenceOut
|
||||||
|
/** Присутствует только когда текущий пользователь — организатор этого вхождения (`is_owner`) — показывает кнопку «Редактировать». */
|
||||||
|
ownedConference?: ConferenceOut
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onEdit?: (conference: ConferenceOut) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRange(startsAt: string, endsAt: string): string {
|
function formatRange(startsAt: string, endsAt: string): string {
|
||||||
@@ -18,14 +23,19 @@ function formatRange(startsAt: string, endsAt: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Быстрый просмотр вхождения конференции по клику на чип в календаре — для
|
* Быстрый просмотр вхождения конференции по клику на чип в календаре — для
|
||||||
* конференций, которые пользователь не организует (не владелец —
|
* всех конференций, включая свои: редактирование теперь отдельное действие
|
||||||
* `is_owner`): свои конференции CalendarPage открывает сразу в форме
|
* (карандашик на чипе или кнопка «Редактировать» здесь, у владельца), сам
|
||||||
* редактирования (`ConferenceFormCard`). Здесь — минимум: когда, статус, вход
|
* клик по чипу всегда открывает это окно. Показывает статус, время, номер и,
|
||||||
* и ссылка на управление; подробности состава участников/организатора — в
|
* для владельца, вход в редактирование; подробности состава
|
||||||
* ховер-карточке при наведении/фокусе на чип (`ConferenceHoverCard`).
|
* участников/организатора — в ховер-карточке при наведении/фокусе на чип
|
||||||
|
* (`ConferenceHoverCard`).
|
||||||
*/
|
*/
|
||||||
export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOccurrenceDialogProps) {
|
export function ConferenceOccurrenceDialog({ occurrence, ownedConference, onClose, onEdit }: ConferenceOccurrenceDialogProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
useModalDismiss(onClose)
|
||||||
|
|
||||||
|
const badge = occurrenceBadge(occurrence, new Date(occurrence.starts_at), new Date(occurrence.ends_at))
|
||||||
|
const BadgeIcon = OCCURRENCE_BADGE_ICON[badge.kind]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}>
|
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}>
|
||||||
@@ -40,13 +50,18 @@ export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOc
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span className={`conf-badge ${badge.kind}`} style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<BadgeIcon style={{ width: 11, height: 11 }} aria-hidden="true" />
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
<Calendar style={{ width: 18, height: 18 }} aria-hidden="true" />
|
<Calendar style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||||
<span className="label">Когда</span>
|
<span className="label">Когда</span>
|
||||||
<span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span>
|
<span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
<Repeat style={{ width: 18, height: 18 }} aria-hidden="true" />
|
<Hash style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||||
<span className="label">Номер</span>
|
<span className="label">Номер</span>
|
||||||
<span>{occurrence.number}</span>
|
<span>{occurrence.number}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,10 +78,18 @@ export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOc
|
|||||||
<Play style={{ width: 16, height: 16 }} aria-hidden="true" />
|
<Play style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||||
Войти
|
Войти
|
||||||
</button>
|
</button>
|
||||||
|
{ownedConference && onEdit && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => onEdit(ownedConference)}>
|
||||||
|
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}>
|
{!ownedConference && (
|
||||||
Управление и редактирование — в <Link to="/my-conferences">«Моих конференциях»</Link>
|
<p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}>
|
||||||
</p>
|
Управление и редактирование — в <Link to="/my-conferences">«Моих конференциях»</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
32
frontend/src/hooks/useModalDismiss.ts
Normal file
32
frontend/src/hooks/useModalDismiss.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общее поведение модалок оболочки (`.modal-overlay`/`.modal-panel`):
|
||||||
|
* закрытие по Escape и возврат фокуса на элемент, который был активен до
|
||||||
|
* открытия модалки (обычно кнопка, её вызвавшая) — доступность по клавиатуре.
|
||||||
|
*/
|
||||||
|
export function useModalDismiss(onClose: () => void) {
|
||||||
|
const previouslyFocused = useRef<Element | null>(null)
|
||||||
|
// Всегда актуальный колбэк без пересоздания подписки на keydown при каждом
|
||||||
|
// рендере (onClose у вызывающих обычно инлайновая функция). Синхронизация —
|
||||||
|
// отдельным эффектом (не прямым присваиванием в теле рендера, см.
|
||||||
|
// react-hooks/refs — рефы нельзя трогать во время рендера).
|
||||||
|
const onCloseRef = useRef(onClose)
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previouslyFocused.current = document.activeElement
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onCloseRef.current()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
if (previouslyFocused.current instanceof HTMLElement) previouslyFocused.current.focus()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
24
frontend/src/lib/occurrenceStatus.ts
Normal file
24
frontend/src/lib/occurrenceStatus.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { CheckCircle2, Clock, Play, Repeat } from 'lucide-react'
|
||||||
|
import type { OccurrenceOut } from '@/api/conferences'
|
||||||
|
|
||||||
|
export type OccurrenceBadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended'
|
||||||
|
|
||||||
|
/** Общая логика статус-бейджа вхождения — используется чипом календаря и информационным окном (`ConferenceOccurrenceDialog`). */
|
||||||
|
export function occurrenceBadge(
|
||||||
|
occ: OccurrenceOut,
|
||||||
|
start: Date | null,
|
||||||
|
end: Date | null,
|
||||||
|
): { kind: OccurrenceBadgeKind; label: string } {
|
||||||
|
const now = new Date()
|
||||||
|
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' }
|
||||||
|
if (end && now > end) return { kind: 'ended', label: 'Завершена' }
|
||||||
|
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' }
|
||||||
|
return { kind: 'scheduled', label: 'Запланирована' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OCCURRENCE_BADGE_ICON: Record<OccurrenceBadgeKind, typeof Clock> = {
|
||||||
|
scheduled: Clock,
|
||||||
|
pinned: Repeat,
|
||||||
|
live: Play,
|
||||||
|
ended: CheckCircle2,
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
|||||||
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
|
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
|
||||||
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
||||||
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
|
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
|
||||||
|
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||||
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
|
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
|
||||||
import { pluralizeConferences } from '@/lib/pluralize'
|
import { pluralizeConferences } from '@/lib/pluralize'
|
||||||
import '@/styles/lobby.css'
|
import '@/styles/lobby.css'
|
||||||
@@ -22,17 +23,17 @@ const EMPTY_OCCURRENCES: OccurrenceOut[] = []
|
|||||||
* Календарь-менеджер запланированных/закреплённых конференций
|
* Календарь-менеджер запланированных/закреплённых конференций
|
||||||
* (см. design/mockups/calendar.html).
|
* (см. design/mockups/calendar.html).
|
||||||
*
|
*
|
||||||
* Редактирование в сайдбаре доступно только для «своих» вхождений — тех,
|
* Форма создания/редактирования (`ConferenceFormCard`) живёт в модалке, не в
|
||||||
* что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые
|
* сетке страницы — календарь занимает всю ширину. Клик по чипу (в т.ч. по
|
||||||
* владельца; там же полная модель — recurrence/duration, которых нет в
|
* своей конференции) открывает информационное окно (`ConferenceOccurrenceDialog`);
|
||||||
* `OccurrenceOut` календарной выборки). Остальные вхождения по клику
|
* редактирование — отдельное действие: карандашик на чипе (только у
|
||||||
* открывают лёгкий информационный диалог с входом и ссылкой на «Мои
|
* владельца, см. `myConferenceById`) или кнопка «Редактировать» в самом
|
||||||
* конференции» — согласованное отклонение от макета, вызванное отсутствием
|
* информационном окне.
|
||||||
* в контракте эндпоинта получения одной конференции по id.
|
|
||||||
*/
|
*/
|
||||||
export function CalendarPage() {
|
export function CalendarPage() {
|
||||||
const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
|
const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
|
||||||
const [range, setRange] = useState<VisibleRange | null>(null)
|
const [range, setRange] = useState<VisibleRange | null>(null)
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||||
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
|
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
|
||||||
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null)
|
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null)
|
||||||
|
|
||||||
@@ -45,21 +46,18 @@ export function CalendarPage() {
|
|||||||
const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
|
const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
|
||||||
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
|
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
|
||||||
|
|
||||||
const handleOccurrenceClick = useCallback(
|
const openCreateForm = useCallback(() => {
|
||||||
(occurrence: OccurrenceOut) => {
|
setEditingConference(null)
|
||||||
// `/conferences/my` может содержать и конференции, куда пользователь
|
setIsFormOpen(true)
|
||||||
// только приглашён участником (не организатор) — редактирование
|
}, [])
|
||||||
// открываем только владельцу, иначе — тот же
|
|
||||||
// информационный диалог, что и для чужих вхождений.
|
const openEditForm = useCallback((conference: ConferenceOut) => {
|
||||||
const owned = myConferenceById.get(occurrence.conference_id)
|
setEditingConference(conference)
|
||||||
if (owned?.is_owner) {
|
setIsFormOpen(true)
|
||||||
setEditingConference(owned)
|
setOccurrenceDialogTarget(null)
|
||||||
} else {
|
}, [])
|
||||||
setOccurrenceDialogTarget(occurrence)
|
|
||||||
}
|
const closeForm = useCallback(() => setIsFormOpen(false), [])
|
||||||
},
|
|
||||||
[myConferenceById],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
|
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
|
||||||
// повторно всякий раз, когда меняется identity его пропсов (см. подробный
|
// повторно всякий раз, когда меняется identity его пропсов (см. подробный
|
||||||
@@ -71,6 +69,8 @@ export function CalendarPage() {
|
|||||||
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
|
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const dialogOwnedConference = occurrenceDialogTarget ? myConferenceById.get(occurrenceDialogTarget.conference_id) : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-shell">
|
<div className="page-shell">
|
||||||
<ShellTopbar />
|
<ShellTopbar />
|
||||||
@@ -83,7 +83,7 @@ export function CalendarPage() {
|
|||||||
разовую встречу или закрепите постоянную конференцию с повторением
|
разовую встречу или закрепите постоянную конференцию с повторением
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setEditingConference(null)}>
|
<button type="button" className="btn btn-primary" onClick={openCreateForm}>
|
||||||
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
|
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||||
Новая конференция
|
Новая конференция
|
||||||
</button>
|
</button>
|
||||||
@@ -95,23 +95,57 @@ export function CalendarPage() {
|
|||||||
onViewChange={setView}
|
onViewChange={setView}
|
||||||
occurrences={occurrences ?? EMPTY_OCCURRENCES}
|
occurrences={occurrences ?? EMPTY_OCCURRENCES}
|
||||||
isLoading={occurrencesLoading}
|
isLoading={occurrencesLoading}
|
||||||
|
myConferenceById={myConferenceById}
|
||||||
onRangeChange={handleRangeChange}
|
onRangeChange={handleRangeChange}
|
||||||
onOccurrenceClick={handleOccurrenceClick}
|
onOccurrenceClick={setOccurrenceDialogTarget}
|
||||||
/>
|
onEditClick={openEditForm}
|
||||||
|
|
||||||
<ConferenceFormCard
|
|
||||||
key={editingConference?.id ?? 'create'}
|
|
||||||
conference={editingConference ?? undefined}
|
|
||||||
onSaved={() => setEditingConference(null)}
|
|
||||||
onDeleted={() => setEditingConference(null)}
|
|
||||||
onCancelEdit={() => setEditingConference(null)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{isFormOpen && (
|
||||||
|
<ConferenceFormModal
|
||||||
|
conference={editingConference}
|
||||||
|
onSaved={closeForm}
|
||||||
|
onDeleted={closeForm}
|
||||||
|
onClose={closeForm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{occurrenceDialogTarget && (
|
{occurrenceDialogTarget && (
|
||||||
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} />
|
<ConferenceOccurrenceDialog
|
||||||
|
occurrence={occurrenceDialogTarget}
|
||||||
|
ownedConference={dialogOwnedConference?.is_owner ? dialogOwnedConference : undefined}
|
||||||
|
onClose={() => setOccurrenceDialogTarget(null)}
|
||||||
|
onEdit={openEditForm}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConferenceFormModalProps {
|
||||||
|
conference: ConferenceOut | null
|
||||||
|
onSaved: () => void
|
||||||
|
onDeleted: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Модалка создания/редактирования — разметка по образцу `ConferenceOccurrenceDialog` (modal-overlay/modal-panel, закрытие по клику на оверлей, Escape и возврат фокуса). */
|
||||||
|
function ConferenceFormModal({ conference, onSaved, onDeleted, onClose }: ConferenceFormModalProps) {
|
||||||
|
useModalDismiss(onClose)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
|
||||||
|
<div className="modal-form-wrap" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<ConferenceFormCard
|
||||||
|
key={conference?.id ?? 'create'}
|
||||||
|
conference={conference ?? undefined}
|
||||||
|
onSaved={onSaved}
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
onCancelEdit={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,17 +7,20 @@
|
|||||||
* (см. комментарий в ConferenceCalendar.tsx).
|
* (см. комментарий в ConferenceCalendar.tsx).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ---------- Грид: сетка вхождений + карточка создания/редактирования ---------- */
|
/* ---------- Календарь во всю ширину контента ---------- */
|
||||||
|
/* Раньше `.layout-grid` была двухколоночной сеткой (календарь + вечно
|
||||||
|
висящая сбоку `.create-card`) — форма создания/редактирования переехала в
|
||||||
|
модалку (`.modal-form-wrap`, см. my-conferences.css), сайдбара больше нет.
|
||||||
|
`min-width:0` оставлен: `.week-card` внутри — единственный элемент грида, и
|
||||||
|
без этого грид-трек не сжимается уже своего min-content (внутренняя таблица
|
||||||
|
FullCalendar с 5 колонками) — классический grid blowout, раздувающий трек и
|
||||||
|
всю страницу шире вьюпорта (см. тот же приём в lobby.css/.action). */
|
||||||
.layout-grid {
|
.layout-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 400px;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
/* min-width:0 — без этого грид-элемент с 1fr-треком не сжимается уже своего
|
|
||||||
min-content (тут это внутренняя таблица FullCalendar с 5 колонками) —
|
|
||||||
классический grid blowout: трек и вся страница раздуваются шире вьюпорта,
|
|
||||||
появляется горизонтальный скролл (см. тот же приём в lobby.css/.action). */
|
|
||||||
.layout-grid > * { min-width: 0; }
|
.layout-grid > * { min-width: 0; }
|
||||||
|
|
||||||
.week-card {
|
.week-card {
|
||||||
@@ -67,12 +70,15 @@
|
|||||||
* Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа.
|
* Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа.
|
||||||
* FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из
|
* FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из
|
||||||
* `classNames`) — переопределяем его внешний вид под чип целиком, без
|
* `classNames`) — переопределяем его внешний вид под чип целиком, без
|
||||||
* отдельного контейнера.
|
* отдельного контейнера. Содержимое — только название (+карандашик у
|
||||||
|
* владельца, время/статус/номер — в ConferenceOccurrenceDialog), поэтому чип —
|
||||||
|
* одна строка (flex row), а не колонка бейдж/время/название, как раньше.
|
||||||
*/
|
*/
|
||||||
.vc-calendar .fc-daygrid-event.conf-chip {
|
.vc-calendar .fc-daygrid-event.conf-chip {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
background: var(--color-surface) !important;
|
background: var(--color-surface) !important;
|
||||||
border: 1px solid var(--color-border) !important;
|
border: 1px solid var(--color-border) !important;
|
||||||
border-radius: var(--radius-md) !important;
|
border-radius: var(--radius-md) !important;
|
||||||
@@ -85,10 +91,34 @@
|
|||||||
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-time,
|
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-time,
|
||||||
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */
|
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */
|
||||||
|
|
||||||
.chip-time { display: block; font: var(--text-caption); color: var(--color-ink-500); text-transform: none; letter-spacing: normal; }
|
/* Цепочка min-width:0 + overflow:hidden + text-overflow:ellipsis — без неё
|
||||||
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); }
|
длинное название распирает чип и вылезает за границы ячейки (в узкой
|
||||||
|
недельной колонке — особенно). `.fc-daygrid-event-harness` — прямой
|
||||||
|
flex-item `.fc-daygrid-day-events` (флекс-колонка) выше по файлу: по
|
||||||
|
умолчанию у флекс-айтема min-width:auto (= min-content), из-за чего он не
|
||||||
|
сжимается уже ширины своего содержимого — тот самый grid/flex-blowout. */
|
||||||
|
.vc-calendar .fc-daygrid-event-harness { min-width: 0; }
|
||||||
|
.chip-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
|
||||||
|
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); min-width: 0; flex: 1; }
|
||||||
|
.chip-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
.chip-lock { width: 13px; height: 13px; flex-shrink: 0; }
|
.chip-lock { width: 13px; height: 13px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.chip-edit {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink-500);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip-edit svg { width: 13px; height: 13px; }
|
||||||
|
.chip-edit:hover { background: var(--color-bg-alt); color: var(--color-ink-900); }
|
||||||
|
|
||||||
.conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; }
|
.conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; }
|
||||||
.conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); }
|
.conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); }
|
||||||
.conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); }
|
.conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); }
|
||||||
@@ -242,17 +272,11 @@
|
|||||||
совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3
|
совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3
|
||||||
— см. комментарий там). Макет calendar.html — desktop-only
|
— см. комментарий там). Макет calendar.html — desktop-only
|
||||||
(`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива
|
(`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива
|
||||||
в нём нет — раскладка ниже подобрана по здравому смыслу: календарь сверху,
|
в нём нет.
|
||||||
карточка создания/редактирования — под ним (сохраняем порядок вёрстки).
|
.layout-grid уже одноколоночная на всех ширинах (§2.2) — двухколоночного
|
||||||
|
960px-брейкпоинта под сайдбар-форму больше нет, форма теперь в модалке
|
||||||
|
(.modal-form-wrap .create-card в my-conferences.css гасит sticky сама).
|
||||||
*/
|
*/
|
||||||
@media (max-width: 960px) {
|
|
||||||
/* Двухколоночный грид (сетка + сайдбар 400px) → одна колонка, сайдбар
|
|
||||||
перестаёт быть sticky (в потоке одной колонки залипание при скролле
|
|
||||||
страницы выглядит и работает хуже, чем простое следование за контентом). */
|
|
||||||
.layout-grid { grid-template-columns: 1fr; }
|
|
||||||
.create-card { position: static; top: auto; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.week-card, .create-card { padding: var(--space-4); }
|
.week-card, .create-card { padding: var(--space-4); }
|
||||||
.week-toolbar { flex-direction: column; align-items: stretch; }
|
.week-toolbar { flex-direction: column; align-items: stretch; }
|
||||||
|
|||||||
@@ -52,13 +52,6 @@
|
|||||||
.icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
.icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||||
.icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); }
|
.icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); }
|
||||||
|
|
||||||
/* ---------- Модалка редактирования (переиспользует ConferenceFormCard) ---------- */
|
|
||||||
/* .create-card сама по себе — визуальная панель (фон/обводка/радиус/паддинг из
|
|
||||||
calendar.css); здесь только гасим её sticky-позиционирование календаря и
|
|
||||||
ограничиваем высоту внутри модалки. */
|
|
||||||
.modal-form-wrap { width: 100%; max-width: 480px; }
|
|
||||||
.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; }
|
|
||||||
|
|
||||||
/* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900)
|
/* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900)
|
||||||
(тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая
|
(тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая
|
||||||
тёмный тултип поверх тёмной страницы); заменена на буквальный #333333
|
тёмный тултип поверх тёмной страницы); заменена на буквальный #333333
|
||||||
|
|||||||
@@ -417,6 +417,14 @@
|
|||||||
.modal-actions--split .modal-actions-group { display: flex; gap: var(--space-3); }
|
.modal-actions--split .modal-actions-group { display: flex; gap: var(--space-3); }
|
||||||
.modal-actions--split .row-actions { display: flex; align-items: center; gap: var(--space-3); }
|
.modal-actions--split .row-actions { display: flex; align-items: center; gap: var(--space-3); }
|
||||||
|
|
||||||
|
/* Модалка вокруг ConferenceFormCard (`.create-card`, «Мои конференции» и
|
||||||
|
календарь) — сама карточка остаётся визуальной панелью
|
||||||
|
(фон/обводка/радиус/паддинг из calendar.css), здесь только гасим её
|
||||||
|
sticky-позиционирование из десктопного сайдбара и ограничиваем высоту
|
||||||
|
внутри модалки, чтобы скроллилась она сама, а не вся страница. */
|
||||||
|
.modal-form-wrap { width: 100%; max-width: 480px; }
|
||||||
|
.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; }
|
||||||
|
|
||||||
/* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются
|
/* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются
|
||||||
(в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на
|
(в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на
|
||||||
свою строку сверху. */
|
свою строку сверху. */
|
||||||
|
|||||||
@@ -162,9 +162,16 @@ async def send_invitations_async(
|
|||||||
body = "\n".join(body_lines)
|
body = "\n".join(body_lines)
|
||||||
|
|
||||||
backend = create_email_backend(settings)
|
backend = create_email_backend(settings)
|
||||||
|
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
||||||
for email in recipients:
|
for email in recipients:
|
||||||
try:
|
try:
|
||||||
await backend.send(to=email, subject=subject, body=body, attachments=[attachment])
|
await backend.send(
|
||||||
|
to=email,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
attachments=[attachment],
|
||||||
|
reply_to=reply_to,
|
||||||
|
)
|
||||||
except EmailSendError as exc:
|
except EmailSendError as exc:
|
||||||
if not exc.retryable:
|
if not exc.retryable:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -156,9 +156,16 @@ async def notify_session_async(
|
|||||||
)
|
)
|
||||||
|
|
||||||
backend = create_email_backend(get_settings())
|
backend = create_email_backend(get_settings())
|
||||||
|
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
||||||
for email in pending:
|
for email in pending:
|
||||||
try:
|
try:
|
||||||
await backend.send(to=email, subject=subject, body=text_body, html_body=html_body)
|
await backend.send(
|
||||||
|
to=email,
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
html_body=html_body,
|
||||||
|
reply_to=reply_to,
|
||||||
|
)
|
||||||
except EmailSendError as exc:
|
except EmailSendError as exc:
|
||||||
if not exc.retryable:
|
if not exc.retryable:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
Reference in New Issue
Block a user