Валидация ai_level срабатывала на любой PUT /admin/settings, включая случаи, когда фронт отправлял уже сохранённый (не изменённый) уровень — на слабом железе это намертво блокировало правку любых других настроек. Бэкенд теперь сравнивает patch.ai_level с текущим значением и проверяет доступность только при фактической смене уровня; фронт дополнительно отправляет в PUT только реально изменённые поля.
460 lines
23 KiB
Python
460 lines
23 KiB
Python
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
|
||
|
||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||
`ai_level`, `summary_recipients`, `display_timezone`,
|
||
`registration_team_choice`, `registration_email_domain`, `contact_email`) —
|
||
новая настройка не требует миграции, только новая строка. Бутстрап (`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,
|
||
InstanceConfig,
|
||
PluginsConfig,
|
||
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_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"
|
||
|
||
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, "domain": None}
|
||
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
|
||
|
||
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
||
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
|
||
# без пробелов — валидация после нормализации (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
|
||
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_domain: str | None = None
|
||
contact_email_enabled: bool | None = None
|
||
contact_email: 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_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),
|
||
}
|
||
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 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.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_domain 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_domain = (
|
||
patch.registration_email_domain
|
||
if patch.registration_email_domain is not None
|
||
else cfg.registration_email_domain
|
||
)
|
||
domain = _normalize_email_domain(raw_domain) if raw_domain else None
|
||
if enabled and domain is None:
|
||
raise InvalidEmailDomainError(
|
||
"нельзя включить верификацию домена email без указания домена"
|
||
)
|
||
cfg.registration_email_domain_enabled = enabled
|
||
cfg.registration_email_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:
|
||
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"))
|
||
|
||
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,
|
||
)
|
||
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_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, {})),
|
||
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_domain=rows.get(
|
||
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
|
||
).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"),
|
||
)
|