diff --git a/backend/services/instance_settings.py b/backend/services/instance_settings.py index 44c7bfb..95f4937 100644 --- a/backend/services/instance_settings.py +++ b/backend/services/instance_settings.py @@ -237,7 +237,20 @@ class InstanceSettingsService: rows = await self._load_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)} if not statuses[patch.ai_level].available: raise InvalidAiLevelError( diff --git a/backend/tests/test_instance_settings.py b/backend/tests/test_instance_settings.py index 90832fc..6870486 100644 --- a/backend/tests/test_instance_settings.py +++ b/backend/tests/test_instance_settings.py @@ -478,6 +478,52 @@ async def test_update_rejects_unavailable_ai_level( 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( db_session: AsyncSession, clean_instance_settings: None ) -> None: diff --git a/frontend/src/components/admin/AdminSettingsTab.tsx b/frontend/src/components/admin/AdminSettingsTab.tsx index 2d4d00f..f5df350 100644 --- a/frontend/src/components/admin/AdminSettingsTab.tsx +++ b/frontend/src/components/admin/AdminSettingsTab.tsx @@ -95,18 +95,34 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { }) function handleSave() { - mutation.mutate({ - chat_enabled: chatEnabled, - transcription_enabled: aiEnabled, - ai_level: aiLevel, - summary_recipients: recipients, - display_timezone: timezone.trim(), - registration_team_choice: teamChoiceEnabled, - registration_email_domain_enabled: domainVerificationEnabled, - registration_email_domain: emailDomain.trim() || null, - contact_email_enabled: contactEmailEnabled, - contact_email: contactEmail.trim() || null, - }) + // Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор + // опциональных полей именно для этого): иначе, например, ai_level + // уходил бы в PUT нетронутым при каждом сохранении и на слабом железе + // валился бы в 400, блокируя правку вообще любой другой настройки. + const payload: SettingsUpdateIn = {} + if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled + if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled + if (aiLevel !== data.ai_level) payload.ai_level = aiLevel + if (recipients !== data.summary_recipients) payload.summary_recipients = recipients + if (timezone.trim() !== data.display_timezone) payload.display_timezone = timezone.trim() + 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 (