Files
vidconf/backend/services/instance_settings.py
Max Ronzhin 4f82ebe17a
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(auth): согласие на обработку персональных данных при регистрации
Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
2026-08-04 22:20:00 +03:00

609 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
(например, при рестарте backend) не перетирает уже сделанные администратором
правки. Воркеры настройки только читают (`load_effective_config`); если
строк ещё нет (воркер стартовал раньше backend) — fallback на `plugins.yaml`
(т.к. воркеры в БД не пишут).
"""
import re
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, EmailStr, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import Settings
from core.plugins.config import (
AiLevel,
ChatConfig,
HandQueueConfig,
InstanceConfig,
MediaLimitsConfig,
PluginsConfig,
PublishQualityCap,
StageMaxTiles,
SummarizerConfig,
SummaryRecipientsMode,
TranscriberConfig,
load_plugins_config,
)
from models.instance_setting import InstanceSetting
from services.ai_levels import detect_ai_levels
from services.ai_tiers import TIERS
_KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat"
_KEY_HAND_QUEUE = "hand_queue"
_KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email"
_KEY_MEDIA_LIMITS = "media_limits"
_KEY_CONSENT_POLICY = "consent_policy"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT,
_KEY_TRANSCRIBER,
_KEY_SUMMARIZER,
_KEY_AI_LEVEL,
)
"""Ключи, которыми управляет матрица «пресет → настройки» инсталлятора —
переиспользуется
`scripts/apply_preset_settings.py`, чтобы не дублировать список строковых
имён ключей `instance_settings`."""
_DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domains": []}
"""Формат значения ключа `registration_email_domain` в БД. До версии с
несколькими доменами хранилась форма `{"enabled": bool, "domain": str|None}`
(один домен) — читающий код (`_extract_email_domains`) понимает обе формы
для обратной совместимости с уже развёрнутыми инстансами; при первом же
`update()` значение переписывается в новую форму (см. `update`)."""
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
_DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25}
DEFAULT_CONSENT_POLICY_TEXT = """Это типовой шаблон для предварительной демонстрации. Текст не проходил проверку юриста и не может использоваться как окончательная редакция без такой проверки. Администратор обязан заменить плейсхолдеры в квадратных скобках и, при необходимости, весь текст — под свою организацию и юрисдикцию.
1. Оператор персональных данных
Оператором персональных данных, обрабатываемых при использовании сервиса [название сервиса], является: [полное наименование организации], [ОГРН/ИНН], адрес места нахождения: [адрес]. Контакты по вопросам обработки персональных данных: [email], [телефон].
2. Правовое основание обработки
Обработка персональных данных осуществляется в соответствии с Конституцией Российской Федерации, Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных» и принятыми в соответствии с ним нормативными правовыми актами, на основании согласия субъекта персональных данных (статья 9 Федерального закона № 152-ФЗ).
3. Состав и цели обработки
При регистрации в сервисе обрабатываются следующие персональные данные: адрес электронной почты, имя и фамилия (или иное указанное пользователем имя), пароль (в виде хеша) [дополнить при необходимости].
Цели обработки: [указать цели — например: создание учётной записи, идентификация пользователя, обеспечение доступа к видеоконференциям, направление служебных уведомлений].
4. Срок обработки и хранения
Персональные данные хранятся в течение [указать срок — например: срока действия учётной записи и установленного законом срока после её удаления] либо до отзыва согласия, если это не противоречит требованиям законодательства.
5. Действия с персональными данными
В отношении персональных данных совершаются следующие действия: сбор, запись, систематизация, накопление, хранение, уточнение, извлечение, использование, передача (в объёме, необходимом для функционирования сервиса), обезличивание, блокирование, удаление, уничтожение.
6. Права субъекта персональных данных
Субъект персональных данных вправе получать информацию о том, как обрабатываются его персональные данные, требовать их уточнения, блокирования или уничтожения, а также отозвать согласие на обработку, обратившись по контактам, указанным в разделе 1.
7. Согласие
Регистрируясь в сервисе, пользователь подтверждает, что ознакомлен с настоящим регламентом и даёт согласие на обработку своих персональных данных на условиях, изложенных выше."""
"""Дефолтный текст регламента (ключ `consent_policy`) — согласован с оператором
до встраивания в код (сессия 30). Шаблон с плейсхолдерами в квадратных
скобках, без указания конкретной организации — администратор обязан
заменить их под свою организацию перед вводом в эксплуатацию."""
_DEFAULT_CONSENT_POLICY_VALUE: dict[str, Any] = {
"enabled": False,
"text": DEFAULT_CONSENT_POLICY_TEXT,
"version": 1,
}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
# без пробелов — валидация после нормализации (strip, «@», lower).
_EMAIL_DOMAIN_PATTERN = re.compile(
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):
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
Используется и сервисным слоем (`InstanceSettingsService.update`), и
(реэкспортом) админ-API Блока C (`api/admin.py`) как тело запроса
`PUT /api/v1/admin/settings` — отдельная API-обёртка не нужна, схема
один в один совпадает с контрактом.
"""
chat_enabled: bool | None = None
hand_queue_enabled: bool | None = None
transcription_enabled: bool | None = None
ai_level: AiLevel | None = None
summary_recipients: SummaryRecipientsMode | None = None
display_timezone: str | None = None
registration_team_choice: bool | None = None
registration_email_domain_enabled: bool | None = None
registration_email_domains: list[str] | None = None
contact_email_enabled: bool | None = None
contact_email: str | None = None
publish_quality_cap: PublishQualityCap | None = None
stage_max_tiles: StageMaxTiles | None = None
consent_required: bool | None = None
consent_policy_text: str | None = None
class BootstrapOverrides(BaseModel):
"""Переопределения дефолтов бутстрапа по пресету инсталлятора (`BOOTSTRAP_*` в `.env`).
Без них бутстрап `instance_settings` импортировал бы `plugins.yaml`,
где всё `enabled: true`, — независимо от выбранного пресета
поставки. Собирается `bootstrap_overrides_from_settings` и применяется
ПОВЕРХ дефолтов `plugins.yaml` перед `INSERT ... ON CONFLICT DO NOTHING`
(`ensure_bootstrapped`) — влияет только на чистую БД (первый запуск);
принудительное обновление уже существующих строк на живой инсталляции —
`scripts/apply_preset_settings.py`.
"""
chat_enabled: bool | None = None
# Единый переключатель «транскрибация+суммаризация» — как `transcription_enabled`
# в `SettingsUpdateIn`, управляет `transcriber.enabled` и `summarizer.enabled` вместе.
ai_enabled: bool | None = None
ai_level: AiLevel | None = None
def bootstrap_overrides_from_settings(settings: Settings) -> BootstrapOverrides:
"""Собрать `BootstrapOverrides` из `BOOTSTRAP_*` полей `core.config.Settings`."""
return BootstrapOverrides(
chat_enabled=settings.bootstrap_chat_enabled,
ai_enabled=settings.bootstrap_transcription_enabled,
ai_level=settings.bootstrap_ai_level,
)
def build_bootstrap_defaults(
plugins: PluginsConfig, overrides: BootstrapOverrides | None = None
) -> dict[str, dict[str, Any]]:
"""Собрать словарь дефолтов всех ключей `instance_settings` из `plugins.yaml`,
применив `overrides` пресета инсталлятора поверх (`chat`/`transcriber`+`summarizer`/`ai_level`).
Переиспользуется `ensure_bootstrapped` (чистая БД) и
`scripts/apply_preset_settings.py` (принудительное обновление живой БД).
"""
defaults: dict[str, dict[str, Any]] = {
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
_KEY_CHAT: plugins.chat.model_dump(mode="json"),
_KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"),
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
_KEY_CONSENT_POLICY: dict(_DEFAULT_CONSENT_POLICY_VALUE),
}
if overrides is None:
return defaults
if overrides.chat_enabled is not None:
defaults[_KEY_CHAT] = {**defaults[_KEY_CHAT], "enabled": overrides.chat_enabled}
if overrides.ai_enabled is not None:
defaults[_KEY_TRANSCRIBER] = {
**defaults[_KEY_TRANSCRIBER],
"enabled": overrides.ai_enabled,
}
defaults[_KEY_SUMMARIZER] = {
**defaults[_KEY_SUMMARIZER],
"enabled": overrides.ai_enabled,
}
if overrides.ai_level is not None:
defaults[_KEY_AI_LEVEL] = {"level": overrides.ai_level}
return defaults
class InvalidAiLevelError(ValueError):
"""Запрошенный уровень AI недоступен (см. `services.ai_levels.detect_ai_levels`)."""
class InvalidTimezoneError(ValueError):
"""`display_timezone` не является валидным именем IANA-таймзоны."""
class InvalidEmailDomainError(ValueError):
"""Некорректная настройка верификации домена email при регистрации.
Поднимается при попытке включить верификацию без домена (`enabled=true`
и пустой/отсутствующий домен) либо при домене, не проходящем валидацию
простым паттерном доменного имени.
"""
class InvalidConsentPolicyError(ValueError):
"""Попытка включить обязательное согласие при пустом тексте регламента
(`consent_required=True` без непустого `consent_policy_text`)."""
class InvalidContactEmailError(ValueError):
"""Некорректная настройка контактного адреса инстанса.
Поднимается при попытке включить контактный адрес без email
(`enabled=true` и пустой/отсутствующий email) либо при email, не
проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`.
"""
class InstanceSettingsService:
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def ensure_bootstrapped(
self, yaml_path: str | Path, overrides: BootstrapOverrides | None = None
) -> None:
"""Импортировать дефолты `plugins.yaml` в `instance_settings` (однократно, идемпотентно).
`overrides` (матрица «пресет → настройки» инсталлятора, см.
`bootstrap_overrides_from_settings`) подменяет `chat.enabled`,
`transcriber.enabled`+`summarizer.enabled` и `ai_level` в дефолтах ДО
`INSERT ... ON CONFLICT DO NOTHING` — влияет только на строки, которых
ещё нет (чистая БД/первый запуск инсталлятора); уже существующие
строки (живая инсталляция, возможно с ручными правками администратора)
не трогает — `ON CONFLICT DO NOTHING` сохраняется как есть.
"""
plugins = load_plugins_config(yaml_path)
defaults = build_bootstrap_defaults(plugins, overrides)
for key, value in defaults.items():
stmt = (
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_nothing(index_elements=["key"])
)
await self._session.execute(stmt)
await self._session.commit()
async def get(self) -> InstanceConfig:
"""Собрать эффективную конфигурацию из текущих строк `instance_settings`."""
rows = await self._load_rows()
return _build_config(rows)
async def update(self, patch: SettingsUpdateIn) -> InstanceConfig:
"""Частично обновить настройки и вернуть новую эффективную конфигурацию.
`transcription_enabled` пишет `enabled` сразу в обе секции
(`transcriber`, `summarizer`) — это единый переключатель
«транскрибация+суммаризация».
"""
rows = await self._load_rows()
cfg = _build_config(rows)
if patch.ai_level is not None and patch.ai_level != cfg.ai_level:
# Валидация только при фактической смене уровня (сравнение с уже
# сохранённым cfg.ai_level) — иначе фронт, отправляющий текущий
# ai_level вместе с любой другой правкой (см. `AdminSettingsTab`),
# блокировал бы сохранение несвязанных настроек на слабом железе,
# где текущий (давно и легитимно сохранённый) уровень недоступен
# по факту заново переоценённых требований (RAM/модели).
#
# Не ослабляем проверку и при отключённых transcriber.enabled/
# summarizer.enabled (когда уровень AI сейчас ни на что не
# влияет): если проверять по факту переключения — это осознанное
# намерение администратора сменить уровень, и молчаливое
# сохранение недоступного значения подставит администратора при
# последующем включении AI неработающей конфигурацией.
statuses = {status.level: status for status in detect_ai_levels(cfg)}
if not statuses[patch.ai_level].available:
raise InvalidAiLevelError(
f"уровень AI {patch.ai_level!r} недоступен: {statuses[patch.ai_level].reason}"
)
cfg.ai_level = patch.ai_level
await self._set(_KEY_AI_LEVEL, {"level": patch.ai_level})
if patch.display_timezone is not None:
_validate_timezone(patch.display_timezone)
cfg.display_timezone = patch.display_timezone
await self._set(_KEY_DISPLAY_TIMEZONE, {"tz": patch.display_timezone})
if patch.summary_recipients is not None:
cfg.summary_recipients = patch.summary_recipients
await self._set(_KEY_SUMMARY_RECIPIENTS, {"mode": patch.summary_recipients})
if patch.chat_enabled is not None:
cfg.chat = ChatConfig(enabled=patch.chat_enabled)
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
if patch.hand_queue_enabled is not None:
cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json"))
if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice
await self._set(
_KEY_REGISTRATION_TEAM_CHOICE, {"enabled": patch.registration_team_choice}
)
if (
patch.registration_email_domain_enabled is not None
or patch.registration_email_domains is not None
):
enabled = (
patch.registration_email_domain_enabled
if patch.registration_email_domain_enabled is not None
else cfg.registration_email_domain_enabled
)
raw_domains = (
patch.registration_email_domains
if patch.registration_email_domains is not None
else cfg.registration_email_domains
)
domains = _normalize_email_domains(raw_domains)
if enabled and not domains:
raise InvalidEmailDomainError(
"нельзя включить верификацию домена email без указания хотя бы одного домена"
)
cfg.registration_email_domain_enabled = enabled
cfg.registration_email_domains = domains
await self._set(
_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domains": domains}
)
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:
cfg.transcriber = cfg.transcriber.model_copy(
update={"enabled": patch.transcription_enabled}
)
cfg.summarizer = cfg.summarizer.model_copy(
update={"enabled": patch.transcription_enabled}
)
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
if patch.publish_quality_cap is not None or patch.stage_max_tiles is not None:
cap = (
patch.publish_quality_cap
if patch.publish_quality_cap is not None
else cfg.media_limits.publish_quality_cap
)
max_tiles = (
patch.stage_max_tiles
if patch.stage_max_tiles is not None
else cfg.media_limits.stage_max_tiles
)
cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles)
await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.model_dump(mode="json"))
if patch.consent_required is not None or patch.consent_policy_text is not None:
consent_required = (
patch.consent_required if patch.consent_required is not None else cfg.consent_required
)
consent_text = (
patch.consent_policy_text.strip()
if patch.consent_policy_text is not None
else cfg.consent_policy_text
)
if consent_required and not consent_text:
raise InvalidConsentPolicyError(
"нельзя включить обязательное согласие с пустым текстом регламента"
)
# Версия — счётчик редакций текста, а не хеш/дата: администратору
# проще сослаться на «редакцию №3», чем на хеш, а инкремент (в
# отличие от даты) однозначно фиксирует факт правки даже при
# повторном сохранении одного и того же текста в одну секунду.
consent_version = cfg.consent_policy_version
if consent_text != cfg.consent_policy_text:
consent_version += 1
cfg.consent_required = consent_required
cfg.consent_policy_text = consent_text
cfg.consent_policy_version = consent_version
await self._set(
_KEY_CONSENT_POLICY,
{"enabled": consent_required, "text": consent_text, "version": consent_version},
)
await self._session.commit()
return cfg
async def _load_rows(self) -> dict[str, Any]:
result = await self._session.execute(select(InstanceSetting))
return {row.key: row.value for row in result.scalars().all()}
async def _set(self, key: str, value: dict[str, Any]) -> None:
stmt = (
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
await self._session.execute(stmt)
async def load_effective_config(session: AsyncSession) -> InstanceConfig:
"""Загрузить эффективную конфигурацию для воркеров.
Если `instance_settings` ещё пуста (воркер стартовал раньше бутстрапа
backend) — fallback на `config/plugins.yaml` напрямую.
Воркеры в БД не пишут — конкурентной гонки с бутстрапом нет.
Уровни `medium`/`max` (ADR-004) перекрывают `transcriber`/
`summarizer` спекой `TIERS[ai_level]` — см. `_apply_tier_overrides`.
"""
result = await session.execute(select(InstanceSetting))
rows = {row.key: row.value for row in result.scalars().all()}
if not rows:
from core.config import get_settings
plugins = load_plugins_config(get_settings().plugins_config_path)
cfg = InstanceConfig(
transcriber=plugins.transcriber,
summarizer=plugins.summarizer,
chat=plugins.chat,
hand_queue=plugins.hand_queue,
)
else:
cfg = _build_config(rows)
return _apply_tier_overrides(cfg)
def _apply_tier_overrides(cfg: InstanceConfig) -> InstanceConfig:
"""Подменить `transcriber`/`summarizer` спекой `TIERS[ai_level]` для `medium`/`max`.
`min` не переопределяется — использует дефолты `plugins.yaml`/правки
администратора как есть (обратная совместимость, ADR-004: min
остаётся конфигурируемым через существующий механизм). Флаг `enabled`
(переключатель «транскрибация+суммаризация» в админке) сохраняется из
текущей конфигурации — подмена per-tier затрагивает только
provider/model/options, не должна повторно включать отключённый модуль.
"""
if cfg.ai_level in ("medium", "max"):
spec = TIERS[cfg.ai_level]
cfg.transcriber = spec.transcriber.model_copy(update={"enabled": cfg.transcriber.enabled})
cfg.summarizer = spec.summarizer.model_copy(update={"enabled": cfg.summarizer.enabled})
return cfg
def _validate_timezone(tz: str) -> None:
"""Проверить, что `tz` — валидное имя IANA-таймзоны."""
try:
ZoneInfo(tz)
except ZoneInfoNotFoundError as exc:
raise InvalidTimezoneError(f"неизвестная таймзона: {tz!r}") from exc
def _normalize_email_domain(domain: str) -> str:
"""Нормализовать домен email (strip, убрать ведущую «@», lower) и провалидировать.
Валидация — простым паттерном доменного имени (минимум одна точка,
допустимые символы, без пробелов); иначе `InvalidEmailDomainError`.
"""
normalized = domain.strip()
if normalized.startswith("@"):
normalized = normalized[1:]
normalized = normalized.lower()
if not _EMAIL_DOMAIN_PATTERN.match(normalized):
raise InvalidEmailDomainError(f"некорректный домен email: {domain!r}")
return normalized
def _normalize_email_domains(domains: list[str]) -> list[str]:
"""Нормализовать список доменов: strip/lower/убрать «@» на каждом (см.
`_normalize_email_domain`), отбросить пустые строки, убрать дубликаты
(с сохранением порядка первого вхождения)."""
normalized: list[str] = []
for raw in domains:
if not raw.strip():
continue
domain = _normalize_email_domain(raw)
if domain not in normalized:
normalized.append(domain)
return normalized
def _extract_email_domains(value: dict[str, Any]) -> list[str]:
"""Достать список доменов из значения ключа `registration_email_domain`,
понимая и текущую форму (`domains: [...]`), и форму до многодоменной
поддержки (`domain: str | None`, один домен) — на проде уже записано
именно старое значение, миграция БД для этого не нужна: следующий же
`update()` перепишет строку в новую форму (см. docstring `update`)."""
if "domains" in value:
return list(value["domains"])
legacy_domain = value.get("domain")
return [legacy_domain] if legacy_domain else []
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:
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
Отсутствие отдельного ключа (например, настройка добавлена уже после
бутстрапа существующего инстанса) не должно ронять чтение конфигурации —
используется дефолт соответствующей Pydantic-модели/константы.
"""
return InstanceConfig(
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
"mode", "all"
),
display_timezone=rows.get(_KEY_DISPLAY_TIMEZONE, _DEFAULT_DISPLAY_TIMEZONE_VALUE).get(
"tz", "Europe/Moscow"
),
registration_team_choice=rows.get(
_KEY_REGISTRATION_TEAM_CHOICE, _DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE
).get("enabled", False),
registration_email_domain_enabled=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("enabled", False),
registration_email_domains=_extract_email_domains(
rows.get(_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE)
),
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"),
media_limits=MediaLimitsConfig.model_validate(
rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE)
),
consent_required=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
"enabled", False
),
consent_policy_text=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
"text", DEFAULT_CONSENT_POLICY_TEXT
),
consent_policy_version=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
"version", 1
),
)