Refuse null for number and on/off settings, and read bad rows as the default (issue #2518)

A PUT /settings with null for a number setting stored the literal
    "None". From then on int()/float() raised inside the response builder,
    and every settings read returned 503 until the row was fixed by hand.

    Explicit null for any boolean or numeric setting is now refused with a
    422 that names the keys, and nothing in that request is saved. A numeric
    row that does not parse reads back as its default with a warning, so an
    install that already stored one recovers. The typed-key lists moved to
    module constants so the save check and the read path share them.
This commit is contained in:
maziggy
2026-09-30 16:17:14 +02:00
3 changed files with 158 additions and 80 deletions
+112 -80
View File
@@ -167,6 +167,96 @@ async def set_setting(db: AsyncSession, key: str, value: str) -> None:
await upsert_setting(db, Settings, key, value)
# Settings stored as booleans / numbers. Storage is a VARCHAR column, so
# _build_settings_response() parses these back, and update_settings() refuses
# an explicit null for them: a null is stored as the literal "None", which
# reads back as False for a boolean and is not a number at all.
_BOOL_SETTING_KEYS = frozenset(
{
"auto_archive",
"save_thumbnails",
"capture_finish_photo",
"finish_photo_restore_plate",
"spoolman_enabled",
"spoolman_disable_weight_sync",
"spoolman_report_partial_usage",
"auto_add_unknown_rfid",
"disable_filament_warnings",
"prefer_lowest_filament",
"check_updates",
"check_printer_firmware",
"include_beta_updates",
"virtual_printer_enabled",
"ftp_retry_enabled",
"mqtt_enabled",
"mqtt_use_tls",
"ha_enabled",
"per_printer_mapping_expanded",
"prometheus_enabled",
"user_notifications_enabled",
"queue_drying_enabled",
"queue_drying_block",
"ambient_drying_enabled",
"print_drying_enabled",
"require_plate_clear",
"queue_shortest_first",
# default_bed_levelling / default_flow_cali / default_nozzle_offset_cali
# are tri-state strings (off/on/auto) — parsed via the raw-string else
# branch; the TriState validator coerces legacy "true"/"false" rows.
"default_vibration_cali",
"default_layer_inspect",
"default_timelapse",
"default_confirm_outcome",
"confirm_outcome_external_prints",
"confirm_default_good_on_plate_clear",
"billing_enabled",
"printer_kill_switch_enabled",
"ldap_enabled",
"ldap_auto_provision",
"local_login_enabled",
"preheat_enabled",
"queue_keep_bed_warm",
}
)
_FLOAT_SETTING_KEYS = frozenset(
{
"default_filament_cost",
"energy_cost_per_kwh",
"ams_temp_good",
"ams_temp_fair",
"library_disk_warning_gb",
"low_stock_threshold",
}
)
_INT_SETTING_KEYS = frozenset(
{
"ams_humidity_good",
"ams_humidity_fair",
"ams_history_retention_days",
"printer_sensor_history_retention_days",
"ftp_retry_count",
"ftp_retry_delay",
"ftp_timeout",
"mqtt_port",
"stagger_group_size",
"stagger_interval_minutes",
"forecast_global_lead_time_days",
"location_sensor_poll_interval",
"finance_budget_reset_day",
"session_max_hours",
"pipeline_max_copies",
"preheat_max_wait_seconds",
"preheat_soak_seconds",
"queue_keep_warm_bed_temp",
"queue_keep_warm_max_minutes",
"queue_max_concurrent_uploads",
"ambient_drying_sustained_minutes",
}
)
async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -> AppSettings:
"""Build the full settings response, scrubbing secrets for API-key callers."""
settings_dict = DEFAULT_SETTINGS.model_dump()
@@ -175,95 +265,27 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
for setting in result.scalars().all():
if setting.key not in settings_dict:
continue
if setting.key in [
"auto_archive",
"save_thumbnails",
"capture_finish_photo",
"finish_photo_restore_plate",
"spoolman_enabled",
"spoolman_disable_weight_sync",
"spoolman_report_partial_usage",
"auto_add_unknown_rfid",
"disable_filament_warnings",
"prefer_lowest_filament",
"check_updates",
"check_printer_firmware",
"include_beta_updates",
"virtual_printer_enabled",
"ftp_retry_enabled",
"mqtt_enabled",
"mqtt_use_tls",
"ha_enabled",
"per_printer_mapping_expanded",
"prometheus_enabled",
"user_notifications_enabled",
"queue_drying_enabled",
"queue_drying_block",
"ambient_drying_enabled",
"print_drying_enabled",
"require_plate_clear",
"queue_shortest_first",
# default_bed_levelling / default_flow_cali / default_nozzle_offset_cali
# are tri-state strings (off/on/auto) — parsed via the raw-string else
# branch; the TriState validator coerces legacy "true"/"false" rows.
"default_vibration_cali",
"default_layer_inspect",
"default_timelapse",
"default_confirm_outcome",
"confirm_outcome_external_prints",
"confirm_default_good_on_plate_clear",
"billing_enabled",
"printer_kill_switch_enabled",
"ldap_enabled",
"ldap_auto_provision",
"local_login_enabled",
"preheat_enabled",
"queue_keep_bed_warm",
]:
if setting.key in _BOOL_SETTING_KEYS:
settings_dict[setting.key] = setting.value.lower() == "true"
elif setting.key in [
"default_filament_cost",
"energy_cost_per_kwh",
"ams_temp_good",
"ams_temp_fair",
"library_disk_warning_gb",
"low_stock_threshold",
]:
settings_dict[setting.key] = float(setting.value)
elif setting.key in _FLOAT_SETTING_KEYS or setting.key in _INT_SETTING_KEYS:
# A value that does not parse (the literal "None" from an old
# null save, or a hand-edited row) keeps the default instead of
# taking the whole settings response down with it.
parse = int if setting.key in _INT_SETTING_KEYS else float
try:
settings_dict[setting.key] = parse(setting.value)
except (TypeError, ValueError):
logger.warning("Setting %s has an unparseable value; using the default", setting.key)
elif setting.key in [
# Nullable floats. Settings storage stringifies None to the literal
# "None", so these cannot go in the list above -- float("None")
# raises and would take the whole settings response with it (#2905).
# "None", which must read back as null here -- not as the default
# the _FLOAT_SETTING_KEYS branch above falls back to (#2905).
"ams_temp_alarm",
]:
try:
settings_dict[setting.key] = float(setting.value)
except (TypeError, ValueError):
settings_dict[setting.key] = None
elif setting.key in [
"ams_humidity_good",
"ams_humidity_fair",
"ams_history_retention_days",
"printer_sensor_history_retention_days",
"ftp_retry_count",
"ftp_retry_delay",
"ftp_timeout",
"mqtt_port",
"stagger_group_size",
"stagger_interval_minutes",
"forecast_global_lead_time_days",
"location_sensor_poll_interval",
"finance_budget_reset_day",
"session_max_hours",
"pipeline_max_copies",
"preheat_max_wait_seconds",
"preheat_soak_seconds",
"queue_keep_warm_bed_temp",
"queue_keep_warm_max_minutes",
"queue_max_concurrent_uploads",
"ambient_drying_sustained_minutes",
]:
settings_dict[setting.key] = int(setting.value)
elif setting.key == "default_printer_id":
settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
elif setting.key == "open_in_slicer":
@@ -308,6 +330,16 @@ async def update_settings(
"""Update application settings."""
update_data = settings_update.model_dump(exclude_unset=True)
# An explicit null for a boolean or numeric setting has no meaning -- these
# are not clearable -- and would be stored as the literal "None".
null_keys = sorted(
key
for key, value in update_data.items()
if value is None and key in (_BOOL_SETTING_KEYS | _FLOAT_SETTING_KEYS | _INT_SETTING_KEYS)
)
if null_keys:
raise HTTPException(status_code=422, detail=f"These settings cannot be null: {', '.join(null_keys)}")
# Safety refusals on disabling local login (#1589). Two failure modes
# would otherwise lock everyone out of the install:
# 1. No enabled OIDC provider exists — nobody could authenticate.