fix(admin): не блокировать сохранение настроек недоступным уровнем AI
Валидация ai_level срабатывала на любой PUT /admin/settings, включая случаи, когда фронт отправлял уже сохранённый (не изменённый) уровень — на слабом железе это намертво блокировало правку любых других настроек. Бэкенд теперь сравнивает patch.ai_level с текущим значением и проверяет доступность только при фактической смене уровня; фронт дополнительно отправляет в PUT только реально изменённые поля.
This commit is contained in:
@@ -237,7 +237,20 @@ class InstanceSettingsService:
|
|||||||
rows = await self._load_rows()
|
rows = await self._load_rows()
|
||||||
cfg = _build_config(rows)
|
cfg = _build_config(rows)
|
||||||
|
|
||||||
if patch.ai_level is not None:
|
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)}
|
statuses = {status.level: status for status in detect_ai_levels(cfg)}
|
||||||
if not statuses[patch.ai_level].available:
|
if not statuses[patch.ai_level].available:
|
||||||
raise InvalidAiLevelError(
|
raise InvalidAiLevelError(
|
||||||
|
|||||||
@@ -478,6 +478,52 @@ async def test_update_rejects_unavailable_ai_level(
|
|||||||
assert cfg.ai_level == "min"
|
assert cfg.ai_level == "min"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_unrelated_setting_passes_when_current_ai_level_unavailable(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""Баг: фронт всегда отправляет текущий `ai_level` вместе с любой другой
|
||||||
|
правкой (см. `AdminSettingsTab.handleSave`) — если уже сохранённый
|
||||||
|
уровень стал недоступен (слабое железо/модель не докачана), это не
|
||||||
|
должно блокировать сохранение несвязанной настройки. Валидация нужна
|
||||||
|
только при фактической смене уровня (см. docstring `update`)."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
stmt = (
|
||||||
|
pg_insert(InstanceSetting)
|
||||||
|
.values(key="ai_level", value={"level": "medium"})
|
||||||
|
.on_conflict_do_update(index_elements=["key"], set_={"value": {"level": "medium"}})
|
||||||
|
)
|
||||||
|
await db_session.execute(stmt)
|
||||||
|
await db_session.commit()
|
||||||
|
assert not detect_ai_levels(await service.get())[1].available # "medium" недоступен
|
||||||
|
|
||||||
|
# ai_level в патче совпадает с уже сохранённым — не должно проверяться.
|
||||||
|
cfg = await service.update(
|
||||||
|
SettingsUpdateIn(ai_level="medium", registration_team_choice=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cfg.ai_level == "medium"
|
||||||
|
assert cfg.registration_team_choice is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_still_rejects_switching_to_unavailable_ai_level(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""Фактическая попытка ПЕРЕКЛЮЧИТЬ уровень на недоступный по-прежнему
|
||||||
|
отклоняется — даже если AI (транскрибация/суммаризация) выключен: иначе
|
||||||
|
администратор молча сохранит нерабочую конфигурацию, которая проявится
|
||||||
|
только при последующем включении AI."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
await service.update(SettingsUpdateIn(transcription_enabled=False))
|
||||||
|
|
||||||
|
with pytest.raises(InvalidAiLevelError):
|
||||||
|
await service.update(SettingsUpdateIn(ai_level="medium"))
|
||||||
|
|
||||||
|
cfg = await service.get()
|
||||||
|
assert cfg.ai_level == "min"
|
||||||
|
|
||||||
|
|
||||||
async def test_update_rejects_invalid_timezone(
|
async def test_update_rejects_invalid_timezone(
|
||||||
db_session: AsyncSession, clean_instance_settings: None
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -95,18 +95,34 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
mutation.mutate({
|
// Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор
|
||||||
chat_enabled: chatEnabled,
|
// опциональных полей именно для этого): иначе, например, ai_level
|
||||||
transcription_enabled: aiEnabled,
|
// уходил бы в PUT нетронутым при каждом сохранении и на слабом железе
|
||||||
ai_level: aiLevel,
|
// валился бы в 400, блокируя правку вообще любой другой настройки.
|
||||||
summary_recipients: recipients,
|
const payload: SettingsUpdateIn = {}
|
||||||
display_timezone: timezone.trim(),
|
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
|
||||||
registration_team_choice: teamChoiceEnabled,
|
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
|
||||||
registration_email_domain_enabled: domainVerificationEnabled,
|
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
|
||||||
registration_email_domain: emailDomain.trim() || null,
|
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
|
||||||
contact_email_enabled: contactEmailEnabled,
|
if (timezone.trim() !== data.display_timezone) payload.display_timezone = timezone.trim()
|
||||||
contact_email: contactEmail.trim() || null,
|
if (teamChoiceEnabled !== data.registration_team_choice) {
|
||||||
})
|
payload.registration_team_choice = teamChoiceEnabled
|
||||||
|
}
|
||||||
|
if (domainVerificationEnabled !== data.registration_email_domain_enabled) {
|
||||||
|
payload.registration_email_domain_enabled = domainVerificationEnabled
|
||||||
|
}
|
||||||
|
const trimmedDomain = emailDomain.trim() || null
|
||||||
|
if (trimmedDomain !== (data.registration_email_domain ?? null)) {
|
||||||
|
payload.registration_email_domain = trimmedDomain
|
||||||
|
}
|
||||||
|
if (contactEmailEnabled !== data.contact_email_enabled) {
|
||||||
|
payload.contact_email_enabled = contactEmailEnabled
|
||||||
|
}
|
||||||
|
const trimmedContactEmail = contactEmail.trim() || null
|
||||||
|
if (trimmedContactEmail !== (data.contact_email ?? null)) {
|
||||||
|
payload.contact_email = trimmedContactEmail
|
||||||
|
}
|
||||||
|
mutation.mutate(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user