mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user