Browse Source

fix(api): null for a field that can't be empty leaves it unchanged

Update schemas declare fields `X | None = None` so a PATCH can leave
them out, and most routes treat null as "unchanged". About a dozen
(printers, smart plugs, queue items and bulk edits, archives,
filaments, spools, notification providers, GitHub backup, HA sensors,
maintenance, external links) applied model_dump(exclude_unset=True)
with setattr instead, wrote the null into a NOT NULL column and failed
with a 500.

A new base, IgnoresNullForRequiredColumns, drops such a null before
validation, using the NOT NULL flags of the table the schema names
(__orm_table__, resolved lazily), so every route treats it as not sent.
A null for a nullable column still clears it. 40 update schemas use it;
a guard test requires every PATCH/PUT body that writes a row to use it
or to be listed with a reason (suppliers keep their own 422 for a null
name).
maziggy 1 tuần trước cách đây
mục cha
commit
8e5116fe20
33 tập tin đã thay đổi với 428 bổ sung và 39 xóa
  1. 1 0
      CHANGELOG.md
  2. 5 2
      backend/app/api/routes/inventory.py
  3. 3 1
      backend/app/api/routes/virtual_printers.py
  4. 5 1
      backend/app/schemas/api_key.py
  5. 3 1
      backend/app/schemas/archive.py
  6. 6 2
      backend/app/schemas/auth.py
  7. 3 1
      backend/app/schemas/connected_app.py
  8. 5 1
      backend/app/schemas/external_link.py
  9. 4 1
      backend/app/schemas/filament.py
  10. 6 2
      backend/app/schemas/finance.py
  11. 4 1
      backend/app/schemas/github_backup.py
  12. 5 1
      backend/app/schemas/group.py
  13. 5 1
      backend/app/schemas/kprofile.py
  14. 14 4
      backend/app/schemas/library.py
  15. 5 1
      backend/app/schemas/local_preset.py
  16. 3 1
      backend/app/schemas/location.py
  17. 4 1
      backend/app/schemas/location_ha_sensor.py
  18. 6 2
      backend/app/schemas/maintenance.py
  19. 54 0
      backend/app/schemas/not_null.py
  20. 4 1
      backend/app/schemas/notification.py
  21. 4 1
      backend/app/schemas/notification_template.py
  22. 5 1
      backend/app/schemas/print_log.py
  23. 9 3
      backend/app/schemas/print_queue.py
  24. 3 1
      backend/app/schemas/printer.py
  25. 5 1
      backend/app/schemas/printer_ha_sensor.py
  26. 5 1
      backend/app/schemas/printer_location.py
  27. 8 2
      backend/app/schemas/project.py
  28. 4 1
      backend/app/schemas/slicer_pipeline.py
  29. 4 1
      backend/app/schemas/smart_plug.py
  30. 3 1
      backend/app/schemas/spool.py
  31. 5 1
      backend/app/schemas/user_notifications.py
  32. 133 0
      backend/tests/integration/test_patch_null_on_required_fields.py
  33. 95 0
      backend/tests/unit/test_not_null_update_schemas.py

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **API: `null` for a field that can't be empty no longer causes a server error** — On most PATCH/PUT routes, `null` for a field leaves it unchanged. About a dozen routes (printers, smart plugs, queue items and bulk edits, archives, filaments, spools, notification providers, GitHub backup, Home Assistant sensors, maintenance, external links) wrote it to the database instead and failed with a server error when the field can't be empty, such as a name or a switch. They now leave it unchanged too, and the rest of the request is applied. `null` for a field that may be empty, such as a printer's location, still clears it. The web interface never sent such values; this affects API clients and scripts.
 - **Webhook routes answer a database problem with 503, not 500** — The `/webhook/...` routes checked API keys with their own copy of the check, which missed earlier fixes. A database error while checking the key, a key too long to hash or a damaged stored key came back as a 500. They now use the same check as every other route: 503 when the key can't be checked right now, 401 when it isn't valid. An expired key now reads "Invalid API key" there too, like everywhere else.
 - **The MQTT relay publishes what the wiki says it does** — Three topics were missing or wrong. `smart_plugs/energy` was never sent: with the relay connected, each enabled plug's power, today's energy and total energy now go out once a minute (without the relay no plug is read for this, and an MQTT plug that stopped reporting is left out). `smart_plugs/on` and `/off` only came from switching a plug by hand; auto-on, auto-off, schedules, the queue powering a printer on and failure detection switching a plug off now publish them too. `maintenance/alert` always said 0 for the current value and the threshold; it now has the hours (or days) since the item was last done, its interval and a `unit` field.
 - **PostgreSQL databases from older versions get the links a new install has** — A database created before v0.2.4.1 (or before v1.2.5.2 for the last one) was missing three links: from a print log entry to its archive and to the user who started it, and from an archive to the library file it was printed from. Deleting an archive, a user or a library file left their ID behind instead of clearing it, so such entries didn't show up under "no user", for example. The links are added on the next start, and IDs of things already deleted are cleared. A print log entry without an archive also stays that way now: every start used to attach it to the newest archive with the same name on the same printer, which could be someone else's. 51 columns, mostly settings flags, also accepted an empty value there or had a default only those databases had. They now match a new install, and an empty value gets the column's default, with two exceptions so nothing gains access: an empty API key permission becomes "off", and an OIDC provider without an email claim, which so far linked no accounts, gets auto-linking turned off instead of starting to link by email. Apart from that, SQLite installs are unchanged.

+ 5 - 2
backend/app/api/routes/inventory.py

@@ -35,6 +35,7 @@ from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier, supplier_name_key
 from backend.app.models.user import User
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.spool import (
     MaterialNumberStats,
     SpoolAssignmentCreate,
@@ -423,7 +424,8 @@ class CatalogEntryCreate(BaseModel):
     weight: int
 
 
-class CatalogEntryUpdate(BaseModel):
+class CatalogEntryUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "spool_catalog"
     name: str
     weight: int
 
@@ -471,7 +473,8 @@ class ColorEntryCreate(BaseModel):
         return normalize_effect_type(v)
 
 
-class ColorEntryUpdate(BaseModel):
+class ColorEntryUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "color_catalog"
     manufacturer: str
     color_name: str
     hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)

+ 3 - 1
backend/app/api/routes/virtual_printers.py

@@ -10,6 +10,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.virtual_printer import VPDiagnosticResult
 
 # Imported at module scope so tests can patch
@@ -45,7 +46,8 @@ class VirtualPrinterCreate(BaseModel):
     remote_interface_ip: str | None = None
 
 
-class VirtualPrinterUpdate(BaseModel):
+class VirtualPrinterUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "virtual_printers"
     name: str | None = None
     enabled: bool | None = None
     mode: str | None = None

+ 5 - 1
backend/app/schemas/api_key.py

@@ -2,6 +2,8 @@ from datetime import datetime
 
 from pydantic import BaseModel
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class APIKeyCreate(BaseModel):
     """Schema for creating a new API key."""
@@ -24,9 +26,11 @@ class APIKeyCreate(BaseModel):
     expires_at: datetime | None = None
 
 
-class APIKeyUpdate(BaseModel):
+class APIKeyUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating an API key."""
 
+    __orm_table__ = "api_keys"
+
     name: str | None = None
     can_queue: bool | None = None
     can_control_printer: bool | None = None

+ 3 - 1
backend/app/schemas/archive.py

@@ -3,6 +3,7 @@ from typing import Annotated
 
 from pydantic import BaseModel, BeforeValidator, Field, model_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.utils.filename import clean_display_name
 
 # Free text, punctuation and all -- only control characters are taken out, and
@@ -27,7 +28,8 @@ class ArchiveBase(BaseModel):
     external_url: str | None = None
 
 
-class ArchiveUpdate(ArchiveBase):
+class ArchiveUpdate(IgnoresNullForRequiredColumns, ArchiveBase):
+    __orm_table__ = "print_archives"
     printer_id: int | None = None
     project_id: int | None = None
     # Allow changing status (e.g., clearing failed flag)

+ 6 - 2
backend/app/schemas/auth.py

@@ -3,6 +3,8 @@ from typing import Literal
 
 from pydantic import BaseModel, Field, field_validator, model_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 def _validate_password_complexity(v: str) -> str:
     """Enforce minimum password complexity (M-C).
@@ -61,7 +63,8 @@ class UserCreate(BaseModel):
         return v
 
 
-class UserUpdate(BaseModel):
+class UserUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "users"
     username: str | None = Field(default=None, max_length=150)
     password: str | None = Field(default=None, max_length=256)  # M-NEW-4: cap before pbkdf2
     email: str | None = Field(default=None, max_length=254)  # L-NEW-5: RFC 5321 max
@@ -552,7 +555,8 @@ class OIDCProviderCreate(BaseModel):
         return self
 
 
-class OIDCProviderUpdate(BaseModel):
+class OIDCProviderUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "oidc_providers"
     name: str | None = Field(default=None, max_length=100)
     issuer_url: str | None = None
 

+ 3 - 1
backend/app/schemas/connected_app.py

@@ -4,6 +4,7 @@ from urllib.parse import urlsplit
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.print_queue import UTCDatetime
 
 # RFC 7636: 43-128 chars from the unreserved set. The challenge is the
@@ -38,7 +39,8 @@ class ConnectedAppCreate(BaseModel):
     _check_redirect = field_validator("redirect_uri")(_validate_redirect_uri)
 
 
-class ConnectedAppUpdate(BaseModel):
+class ConnectedAppUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "connected_apps"
     name: str | None = Field(default=None, min_length=1, max_length=100)
     redirect_uri: str | None = Field(default=None, min_length=1, max_length=500)
     enabled: bool | None = None

+ 5 - 1
backend/app/schemas/external_link.py

@@ -2,6 +2,8 @@ from datetime import datetime
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class ExternalLinkBase(BaseModel):
     """Base schema for external links."""
@@ -26,9 +28,11 @@ class ExternalLinkCreate(ExternalLinkBase):
     pass
 
 
-class ExternalLinkUpdate(BaseModel):
+class ExternalLinkUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating an external link (all fields optional)."""
 
+    __orm_table__ = "external_links"
+
     name: str | None = Field(default=None, min_length=1, max_length=50)
     url: str | None = Field(default=None, min_length=1, max_length=500)
     icon: str | None = Field(default=None, max_length=50)

+ 4 - 1
backend/app/schemas/filament.py

@@ -2,6 +2,8 @@ from datetime import datetime
 
 from pydantic import BaseModel, Field
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class FilamentBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
@@ -23,7 +25,8 @@ class FilamentCreate(FilamentBase):
     pass
 
 
-class FilamentUpdate(BaseModel):
+class FilamentUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "filaments"
     name: str | None = None
     type: str | None = None
     brand: str | None = None

+ 6 - 2
backend/app/schemas/finance.py

@@ -3,6 +3,8 @@ from typing import Literal
 
 from pydantic import BaseModel, Field
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class WalletBalanceResponse(BaseModel):
     user_id: int
@@ -66,7 +68,8 @@ class WalletAdjustmentResponse(BaseModel):
     balance: WalletBalanceResponse
 
 
-class TransactionEditRequest(BaseModel):
+class TransactionEditRequest(IgnoresNullForRequiredColumns):
+    __orm_table__ = "wallet_transactions"
     user_id: int | None = None
     cost_center_id: int | None = None
     amount: float | None = None
@@ -88,7 +91,8 @@ class CostCenterCreateRequest(BaseModel):
     is_active: bool = True
 
 
-class CostCenterUpdateRequest(BaseModel):
+class CostCenterUpdateRequest(IgnoresNullForRequiredColumns):
+    __orm_table__ = "cost_centers"
     name: str | None = Field(default=None, min_length=1, max_length=150)
     is_active: bool | None = None
 

+ 4 - 1
backend/app/schemas/github_backup.py

@@ -6,6 +6,7 @@ from datetime import datetime
 from pydantic import BaseModel, Field, model_validator
 
 from backend.app.core.compat import StrEnum
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 
 
 class ScheduleType(StrEnum):
@@ -69,9 +70,11 @@ class GitHubBackupConfigCreate(BaseModel):
         )
 
 
-class GitHubBackupConfigUpdate(BaseModel):
+class GitHubBackupConfigUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating GitHub backup config (all fields optional)."""
 
+    __orm_table__ = "github_backup_config"
+
     repository_url: str | None = Field(default=None, max_length=500)
     access_token: str | None = Field(default=None)
     branch: str | None = Field(default=None, max_length=100)

+ 5 - 1
backend/app/schemas/group.py

@@ -4,6 +4,8 @@ from datetime import datetime
 
 from pydantic import BaseModel
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class GroupBrief(BaseModel):
     """Brief group info for embedding in other responses."""
@@ -28,9 +30,11 @@ class GroupCreate(BaseModel):
     locations: list[str] = []
 
 
-class GroupUpdate(BaseModel):
+class GroupUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating a group."""
 
+    __orm_table__ = "groups"
+
     name: str | None = None
     description: str | None = None
     permissions: list[str] | None = None

+ 5 - 1
backend/app/schemas/kprofile.py

@@ -2,6 +2,8 @@
 
 from pydantic import BaseModel
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class KProfile(BaseModel):
     """A pressure advance (K) calibration profile stored on the printer."""
@@ -53,9 +55,11 @@ class KProfileDelete(BaseModel):
     setting_id: str | None = None  # Setting ID (for X1C series)
 
 
-class KProfileNote(BaseModel):
+class KProfileNote(IgnoresNullForRequiredColumns):
     """Schema for K-profile notes (stored locally, not on printer)."""
 
+    __orm_table__ = "kprofile_notes"
+
     setting_id: str  # Unique identifier for the K-profile
     note: str  # The note content
 

+ 14 - 4
backend/app/schemas/library.py

@@ -4,6 +4,8 @@ from datetime import datetime
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 # ============ Folder Schemas ============
 
 
@@ -26,9 +28,11 @@ class ExternalFolderCreate(BaseModel):
     parent_id: int | None = None
 
 
-class FolderUpdate(BaseModel):
+class FolderUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating a folder."""
 
+    __orm_table__ = "library_folders"
+
     name: str | None = Field(None, min_length=1, max_length=255)
     parent_id: int | None = None
     project_id: int | None = None  # 0 to unlink
@@ -132,9 +136,11 @@ class FileCreate(BaseModel):
     project_id: int | None = None
 
 
-class FileUpdate(BaseModel):
+class FileUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating a file."""
 
+    __orm_table__ = "library_files"
+
     filename: str | None = Field(None, min_length=1, max_length=255)
     folder_id: int | None = None
     project_id: int | None = None
@@ -295,9 +301,11 @@ class TagCreate(BaseModel):
     name: str = Field(..., min_length=1, max_length=64)
 
 
-class TagUpdate(BaseModel):
+class TagUpdate(IgnoresNullForRequiredColumns):
     """Rename a tag. ``name`` is required — there's nothing else to update."""
 
+    __orm_table__ = "library_tags"
+
     name: str = Field(..., min_length=1, max_length=64)
 
 
@@ -512,13 +520,15 @@ class VariantGroupCreate(BaseModel):
     name: str | None = Field(None, max_length=255)
 
 
-class VariantGroupUpdate(BaseModel):
+class VariantGroupUpdate(IgnoresNullForRequiredColumns):
     """Rename a group and/or re-order its members.
 
     ``member_file_ids`` must list exactly the group's current members; a partial
     list is rejected rather than guessing where the omitted ones belong.
     """
 
+    __orm_table__ = "file_variant_groups"
+
     name: str | None = Field(None, max_length=255)
     member_file_ids: list[int] | None = None
 

+ 5 - 1
backend/app/schemas/local_preset.py

@@ -4,6 +4,8 @@ from datetime import datetime
 
 from pydantic import BaseModel
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class LocalPresetResponse(BaseModel):
     """Local preset summary (without full setting blob)."""
@@ -43,9 +45,11 @@ class LocalPresetCreate(BaseModel):
     setting: dict
 
 
-class LocalPresetUpdate(BaseModel):
+class LocalPresetUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating a local preset."""
 
+    __orm_table__ = "local_presets"
+
     name: str | None = None
     setting: dict | None = None
 

+ 3 - 1
backend/app/schemas/location.py

@@ -2,6 +2,7 @@ from datetime import datetime
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.services.location_service import normalize_location_name
 
 
@@ -15,7 +16,8 @@ class LocationCreate(BaseModel):
         return normalize_location_name(v)
 
 
-class LocationUpdate(BaseModel):
+class LocationUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "locations"
     name: str | None = Field(default=None, min_length=1, max_length=255)
     identifier: str | None = Field(default=None, max_length=100)
 

+ 4 - 1
backend/app/schemas/location_ha_sensor.py

@@ -5,6 +5,7 @@ from typing import Literal
 
 from pydantic import BaseModel, Field, model_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.printer_ha_sensor import HADisplayEntity  # noqa: F401
 
 
@@ -66,10 +67,12 @@ class LocationHASensorCreate(LocationHASensorBase):
     pass
 
 
-class LocationHASensorUpdate(BaseModel):
+class LocationHASensorUpdate(IgnoresNullForRequiredColumns):
     """Partial update. Validated against the merged row in the route, because
     the per-kind rules above need fields this payload may not carry."""
 
+    __orm_table__ = "location_ha_sensors"
+
     name: str | None = Field(default=None, min_length=1, max_length=100)
     # Same column-width bound as the base schema; PATCH reaches the same row.
     entity_id: str | None = Field(default=None, max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")

+ 6 - 2
backend/app/schemas/maintenance.py

@@ -4,6 +4,8 @@ from datetime import datetime
 
 from pydantic import BaseModel, Field
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 # Maintenance Type schemas
 class MaintenanceTypeBase(BaseModel):
@@ -20,7 +22,8 @@ class MaintenanceTypeCreate(MaintenanceTypeBase):
     pass
 
 
-class MaintenanceTypeUpdate(BaseModel):
+class MaintenanceTypeUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "maintenance_types"
     name: str | None = None
     description: str | None = None
     default_interval_hours: float | None = Field(default=None, ge=1.0)
@@ -50,7 +53,8 @@ class PrinterMaintenanceCreate(PrinterMaintenanceBase):
     pass
 
 
-class PrinterMaintenanceUpdate(BaseModel):
+class PrinterMaintenanceUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "printer_maintenance"
     custom_interval_hours: float | None = None
     custom_interval_type: str | None = Field(default=None, pattern="^(hours|days)$")
     enabled: bool | None = None

+ 54 - 0
backend/app/schemas/not_null.py

@@ -0,0 +1,54 @@
+"""Update schemas treat an explicit null for a column that can't be empty as "unchanged"."""
+
+from typing import Any, ClassVar
+
+from pydantic import BaseModel, model_validator
+
+
+class IgnoresNullForRequiredColumns(BaseModel):
+    """Base for update (PATCH/PUT) schemas that are applied to one ORM row.
+
+    Their fields are mostly ``X | None = None`` so a request can leave them out,
+    and the API's contract is that null means "unchanged": most routes apply a
+    field only when it is not None, and the OpenAPI schema allows null. Routes
+    that apply what was sent with ``model_dump(exclude_unset=True)`` instead
+    wrote the null into a NOT NULL column and failed with a 500. This drops such
+    a null before validation, so the field counts as not sent on every route; a
+    null for a column that may be empty still clears it.
+
+    ``__orm_table__`` names the table the schema writes to. It is looked up when
+    a request is validated, so schemas don't import the models.
+    """
+
+    __orm_table__: ClassVar[str]
+
+    @model_validator(mode="before")
+    @classmethod
+    def _ignore_null_for_required_columns(cls, data: Any) -> Any:
+        if not isinstance(data, dict):
+            return data
+        columns = _table(cls.__orm_table__).c
+        by_key = {(info.alias or name): name for name, info in cls.model_fields.items()} | {
+            name: name for name in cls.model_fields
+        }
+        return {
+            key: value
+            for key, value in data.items()
+            if not (value is None and key in by_key and by_key[key] in columns and not columns[by_key[key]].nullable)
+        }
+
+
+def _table(name: str):
+    from backend.app.core.database import Base
+
+    if name not in Base.metadata.tables:
+        # Every route imports the model it writes, so this only happens when a
+        # schema is validated on its own (a test, a script).
+        import importlib
+        import pkgutil
+
+        import backend.app.models
+
+        for module in pkgutil.iter_modules(backend.app.models.__path__):
+            importlib.import_module(f"backend.app.models.{module.name}")
+    return Base.metadata.tables[name]

+ 4 - 1
backend/app/schemas/notification.py

@@ -6,6 +6,7 @@ from typing import Any, Literal
 from pydantic import BaseModel, Field, field_validator, model_validator
 
 from backend.app.core.compat import StrEnum
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 
 
 class ProviderType(StrEnum):
@@ -164,9 +165,11 @@ class NotificationProviderCreate(NotificationProviderBase):
     pass
 
 
-class NotificationProviderUpdate(BaseModel):
+class NotificationProviderUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating a notification provider (all fields optional)."""
 
+    __orm_table__ = "notification_providers"
+
     name: str | None = Field(default=None, min_length=1, max_length=100)
     provider_type: ProviderType | None = None
     enabled: bool | None = None

+ 4 - 1
backend/app/schemas/notification_template.py

@@ -5,6 +5,7 @@ from datetime import datetime
 from pydantic import BaseModel, Field
 
 from backend.app.core.compat import StrEnum
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 
 
 class EventType(StrEnum):
@@ -454,9 +455,11 @@ class NotificationTemplateBase(BaseModel):
     body_template: str = Field(..., min_length=1, max_length=2000)
 
 
-class NotificationTemplateUpdate(BaseModel):
+class NotificationTemplateUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating a notification template."""
 
+    __orm_table__ = "notification_templates"
+
     title_template: str | None = Field(default=None, min_length=1, max_length=200)
     body_template: str | None = Field(default=None, min_length=1, max_length=2000)
 

+ 5 - 1
backend/app/schemas/print_log.py

@@ -2,6 +2,8 @@ from datetime import datetime
 
 from pydantic import BaseModel, ConfigDict
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class PrintLogEntrySchema(BaseModel):
     # from_attributes lets the routes build this straight off the ORM row.
@@ -41,7 +43,7 @@ class PrintLogResponse(BaseModel):
     total: int
 
 
-class PrintLogEntryUpdate(BaseModel):
+class PrintLogEntryUpdate(IgnoresNullForRequiredColumns):
     """Per-row classification edits (#1687 part 4 — IndividualGhost1905).
 
     Lets the user set ``failure_reason`` (and re-classify ``status``) directly
@@ -50,5 +52,7 @@ class PrintLogEntryUpdate(BaseModel):
     ``PrintLogEntry.failure_reason``, so this just plugs the editor gap.
     """
 
+    __orm_table__ = "print_log_entries"
+
     failure_reason: str | None = None
     status: str | None = None

+ 9 - 3
backend/app/schemas/print_queue.py

@@ -3,6 +3,7 @@ from typing import Annotated, Literal
 
 from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
 
 
@@ -137,7 +138,8 @@ class PrintQueueItemCreate(BaseModel):
     variants: list[QueueVariantCreate] | None = None
 
 
-class PrintQueueItemUpdate(BaseModel):
+class PrintQueueItemUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "print_queue"
     printer_id: int | None = None
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     target_location: str | None = None  # Target location filter (only used with target_model)
@@ -328,9 +330,11 @@ class PrintQueueReorder(BaseModel):
         return self
 
 
-class PrintQueueBulkUpdate(BaseModel):
+class PrintQueueBulkUpdate(IgnoresNullForRequiredColumns):
     """Bulk update multiple queue items with the same values."""
 
+    __orm_table__ = "print_queue"
+
     item_ids: list[int]
     # Fields to update (all optional - only set fields are applied)
     printer_id: int | None = None
@@ -412,13 +416,15 @@ class PrintBatchCreate(BaseModel):
         return self
 
 
-class PrintBatchUpdate(BaseModel):
+class PrintBatchUpdate(IgnoresNullForRequiredColumns):
     """Edit an order's header or its per-plate targets while it runs.
 
     Every field is optional; ``plates`` replaces the full target set when
     given, so a plate omitted from the list has its target row removed.
     """
 
+    __orm_table__ = "print_batches"
+
     name: str | None = None
     status: Literal["active", "cancelled"] | None = None
     plates: list[PrintBatchPlateTarget] | None = None

+ 3 - 1
backend/app/schemas/printer.py

@@ -2,6 +2,7 @@ from datetime import datetime
 
 from pydantic import BaseModel, Field, field_validator, model_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.printer_location import normalize_location_name
 from backend.app.utils.printer_models import supports_nozzle_flow_type
 
@@ -63,7 +64,8 @@ class PlateDetectionROI(BaseModel):
     h: float = Field(..., ge=0.0, le=1.0)  # Height %
 
 
-class PrinterUpdate(BaseModel):
+class PrinterUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "printers"
     name: str | None = None
     ip_address: str | None = Field(
         default=None,

+ 5 - 1
backend/app/schemas/printer_ha_sensor.py

@@ -5,6 +5,8 @@ from typing import Literal
 
 from pydantic import BaseModel, Field, model_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class PrinterHASensorBase(BaseModel):
     printer_id: int
@@ -58,10 +60,12 @@ class PrinterHASensorCreate(PrinterHASensorBase):
     pass
 
 
-class PrinterHASensorUpdate(BaseModel):
+class PrinterHASensorUpdate(IgnoresNullForRequiredColumns):
     """Partial update. Validated against the merged row in the route, because
     the per-kind rules above need fields this payload may not carry."""
 
+    __orm_table__ = "printer_ha_sensors"
+
     name: str | None = Field(default=None, min_length=1, max_length=100)
     # Same column-width bound as the base schema; PATCH reaches the same row.
     entity_id: str | None = Field(default=None, max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")

+ 5 - 1
backend/app/schemas/printer_location.py

@@ -2,6 +2,8 @@
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 # printers.location, print_queue.target_location and printer_locations.name are
 # all VARCHAR(100). PostgreSQL refuses a longer value with an error mid-write,
 # so it is refused here instead.
@@ -72,13 +74,15 @@ class PrinterLocationCreate(BaseModel):
     _blank = field_validator("icon", "color", mode="before")(_blank_to_none)
 
 
-class PrinterLocationUpdate(BaseModel):
+class PrinterLocationUpdate(IgnoresNullForRequiredColumns):
     """Rename and/or restyle the location called ``name``.
 
     ``icon`` and ``color`` are only changed when sent; sending null or "" clears
     them.
     """
 
+    __orm_table__ = "printer_locations"
+
     name: str
     new_name: str | None = None
     icon: str | None = Field(default=None, pattern=_ICON_PATTERN)

+ 8 - 2
backend/app/schemas/project.py

@@ -2,6 +2,8 @@ from datetime import datetime
 
 from pydantic import BaseModel, field_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 def _validate_project_url(value: str | None) -> str | None:
     """Reject anything that isn't an http(s) URL — the URL is rendered as a
@@ -41,9 +43,11 @@ class ProjectCreate(BaseModel):
         return _validate_project_url(v)
 
 
-class ProjectUpdate(BaseModel):
+class ProjectUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating a project."""
 
+    __orm_table__ = "projects"
+
     name: str | None = None
     description: str | None = None
     color: str | None = None
@@ -232,9 +236,11 @@ class BOMItemCreate(BaseModel):
     remarks: str | None = None
 
 
-class BOMItemUpdate(BaseModel):
+class BOMItemUpdate(IgnoresNullForRequiredColumns):
     """Schema for updating a BOM item."""
 
+    __orm_table__ = "project_bom_items"
+
     name: str | None = None
     quantity_needed: int | None = None
     quantity_acquired: int | None = None

+ 4 - 1
backend/app/schemas/slicer_pipeline.py

@@ -12,6 +12,7 @@ from typing import Literal
 
 from pydantic import BaseModel, Field
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.slicer import PresetRef
 
 
@@ -35,11 +36,13 @@ class SlicerPipelineCreate(SlicerPipelineBase):
     """Payload for POST /slicer-pipelines."""
 
 
-class SlicerPipelineUpdate(BaseModel):
+class SlicerPipelineUpdate(IgnoresNullForRequiredColumns):
     """Payload for PUT /slicer-pipelines/{id}. All fields optional; only those
     present are written. Preset and filament list are replaced wholesale when
     set (we don't support partial filament-slot edits)."""
 
+    __orm_table__ = "slicer_pipelines"
+
     name: str | None = Field(default=None, min_length=1, max_length=200)
     description: str | None = Field(default=None, max_length=1000)
     printer_preset: PresetRef | None = None

+ 4 - 1
backend/app/schemas/smart_plug.py

@@ -3,6 +3,8 @@ from typing import Literal
 
 from pydantic import BaseModel, Field, model_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class SmartPlugBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
@@ -121,7 +123,8 @@ class SmartPlugCreate(SmartPlugBase):
     pass
 
 
-class SmartPlugUpdate(BaseModel):
+class SmartPlugUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "smart_plugs"
     name: str | None = None
     plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] | None = None
     ip_address: str | None = None

+ 3 - 1
backend/app/schemas/spool.py

@@ -2,6 +2,7 @@ from datetime import datetime, timezone
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.supplier import SpoolSupplierResponse
 
 # Visual variant applied to a spool's swatch — purely cosmetic, does not
@@ -174,7 +175,8 @@ class SpoolBulkCreate(BaseModel):
     quantity: int = Field(default=1, ge=1, le=100)
 
 
-class SpoolUpdate(BaseModel):
+class SpoolUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "spool"
     material: str | None = None
     subtype: str | None = None
     color_name: str | None = None

+ 5 - 1
backend/app/schemas/user_notifications.py

@@ -2,6 +2,8 @@
 
 from pydantic import BaseModel
 
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+
 
 class UserEmailPreferenceResponse(BaseModel):
     """Response schema for user email notification preferences."""
@@ -15,9 +17,11 @@ class UserEmailPreferenceResponse(BaseModel):
         from_attributes = True
 
 
-class UserEmailPreferenceUpdate(BaseModel):
+class UserEmailPreferenceUpdate(IgnoresNullForRequiredColumns):
     """Update schema for user email notification preferences."""
 
+    __orm_table__ = "user_email_preferences"
+
     notify_print_start: bool
     notify_print_complete: bool
     notify_print_failed: bool

+ 133 - 0
backend/tests/integration/test_patch_null_on_required_fields.py

@@ -0,0 +1,133 @@
+"""An explicit null for a field that can't be empty leaves it unchanged.
+
+Update schemas declare most fields ``X | None = None`` so a PATCH can leave
+them out, and most routes treat null as "unchanged". Routes that apply
+``model_dump(exclude_unset=True)`` with setattr instead wrote a JSON null
+straight into a NOT NULL column, and the request ended in a 500. The schema
+now drops such a null before the route sees it, so every route treats it as
+"unchanged"; a null for a column that may be empty still clears it.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_smart_plug(async_client: AsyncClient, smart_plug_factory, db_session):
+    plug = await smart_plug_factory(username="admin", controls_printer_power=False)
+
+    kept = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"controls_printer_power": None})
+    cleared = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"username": None})
+
+    assert kept.status_code == 200, kept.text
+    assert kept.json()["controls_printer_power"] is False
+    assert cleared.status_code == 200, cleared.text
+    assert cleared.json()["username"] is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_printer(async_client: AsyncClient, printer_factory, db_session):
+    printer = await printer_factory(location="Shelf")
+
+    kept = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"camera_light_auto": None})
+    cleared = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"location": None})
+
+    assert kept.status_code == 200, kept.text
+    assert kept.json()["camera_light_auto"] is False
+    assert cleared.status_code == 200, cleared.text
+    assert cleared.json()["location"] is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_archive(async_client: AsyncClient, printer_factory, archive_factory, db_session):
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, notes="first", quantity=3)
+
+    kept = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": None})
+    cleared = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": None})
+
+    assert kept.status_code == 200, kept.text
+    assert kept.json()["quantity"] == 3
+    assert cleared.status_code == 200, cleared.text
+    assert cleared.json()["notes"] is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_notification_provider(async_client: AsyncClient, notification_provider_factory, db_session):
+    provider = await notification_provider_factory(name="Phone")
+
+    kept = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"name": None})
+
+    assert kept.status_code == 200, kept.text
+    assert kept.json()["name"] == "Phone"
+
+
+async def _queue_item(db_session, printer_factory, archive_factory):
+    from backend.app.models.print_queue import PrintQueueItem
+
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id)
+    item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, position=1, bed_levelling="off")
+    db_session.add(item)
+    await db_session.commit()
+    return item
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_queue_item(async_client: AsyncClient, printer_factory, archive_factory, db_session):
+    item = await _queue_item(db_session, printer_factory, archive_factory)
+
+    kept = await async_client.patch(f"/api/v1/queue/{item.id}", json={"bed_levelling": None})
+
+    assert kept.status_code == 200, kept.text
+    assert kept.json()["bed_levelling"] == "off"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_queue_bulk_update(async_client: AsyncClient, printer_factory, archive_factory, db_session):
+    item = await _queue_item(db_session, printer_factory, archive_factory)
+
+    response = await async_client.patch(
+        "/api/v1/queue/bulk", json={"item_ids": [item.id], "flow_cali": None, "bed_levelling": "on"}
+    )
+
+    assert response.status_code == 200, response.text
+    await db_session.refresh(item)
+    assert item.bed_levelling == "on"
+    assert item.flow_cali == "auto"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_github_backup_config(async_client: AsyncClient, db_session):
+    from backend.app.models.github_backup import GitHubBackupConfig
+
+    db_session.add(GitHubBackupConfig(repository_url="https://github.com/o/r", access_token="t", branch="main"))
+    await db_session.commit()
+
+    kept = await async_client.patch("/api/v1/github-backup/config", json={"provider": None})
+
+    assert kept.status_code == 200, kept.text
+    assert kept.json()["provider"] == "github"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_null_next_to_a_real_change_does_not_block_it(async_client: AsyncClient, db_session):
+    """On the routes that already skipped a null (projects among them) the rest
+    of the request was applied; it must stay that way."""
+    created = await async_client.post("/api/v1/projects/", json={"name": "Shelf"})
+    assert created.status_code == 200, created.text
+    project_id = created.json()["id"]
+
+    response = await async_client.patch(f"/api/v1/projects/{project_id}", json={"name": None, "notes": "x"})
+
+    assert response.status_code == 200, response.text
+    assert response.json()["name"] == "Shelf"
+    assert response.json()["notes"] == "x"

+ 95 - 0
backend/tests/unit/test_not_null_update_schemas.py

@@ -0,0 +1,95 @@
+"""Update schemas treat an explicit null for a NOT NULL column as "unchanged" (schemas/not_null.py).
+
+The route tests are in tests/integration/test_patch_null_on_required_fields.py.
+The guard below keeps every PATCH/PUT body that writes an ORM row on the mixin,
+so a new route can't bring the 500 back.
+"""
+
+from fastapi.routing import APIRoute
+from pydantic import Field
+
+from backend.app.schemas.not_null import IgnoresNullForRequiredColumns, _table
+
+# PATCH/PUT bodies that don't update a row of their own, and why.
+NOT_A_ROW_UPDATE = {
+    "AppSettingsUpdate": "key/value settings; rejects null itself",
+    "SupplierUpdate": "rejects a null name itself with a 422 (its only NOT NULL field)",
+    "ArchivePurgeSettings": "a setting, every field required",
+    "TrashSettings": "a setting, every field required",
+    "DisplaySettingsRequest": "SpoolBuddy device display, every field required",
+    "ManyfoldConfigUpdate": "stored as settings",
+    "CostCenterBudgetUpdateRequest": "replaces the budget rows as a whole",
+    "AmsLabelBody": "upserts a label, every field required",
+    "LinkTagRequest": "links a tag through its own rules",
+    "ShoppingListItemStatusUpdate": "one required field",
+    "ExternalLinkReorder": "a list of ids",
+    "SlicerSettingUpdate": "a Bambu Cloud setting, not a database row",
+    "SpoolmanInventoryUpdate": "a Spoolman spool, not a database row",
+    "SpoolWeightUpdate": "a Spoolman spool, not a database row",
+    "SpoolTagLinkRequest": "a Spoolman spool, not a database row",
+    "SpoolmanFilamentPatch": "a Spoolman filament, not a database row",
+}
+
+
+def test_every_row_update_body_ignores_null_for_required_columns():
+    from backend.app.main import app
+
+    unguarded = []
+    for route in app.routes:
+        if not (isinstance(route, APIRoute) and route.methods & {"PATCH", "PUT"} and route.body_field):
+            continue
+        schema = route.body_field.field_info.annotation
+        if not isinstance(schema, type) or not hasattr(schema, "model_fields"):
+            continue  # a list or dict body
+        if issubclass(schema, IgnoresNullForRequiredColumns) or schema.__name__ in NOT_A_ROW_UPDATE:
+            continue
+        unguarded.append(f"{schema.__name__} ({route.path})")
+    assert unguarded == []
+
+
+def test_every_declared_table_exists():
+    from backend.app.main import app
+
+    tables = {
+        route.body_field.field_info.annotation.__orm_table__
+        for route in app.routes
+        if isinstance(route, APIRoute)
+        and route.body_field
+        and isinstance(route.body_field.field_info.annotation, type)
+        and issubclass(route.body_field.field_info.annotation, IgnoresNullForRequiredColumns)
+    }
+    assert tables
+    for name in tables:
+        _table(name)  # raises KeyError for a misspelt table
+
+
+class _PlugUpdate(IgnoresNullForRequiredColumns):
+    __orm_table__ = "smart_plugs"
+
+    name: str | None = None  # NOT NULL column
+    username: str | None = None  # nullable column
+    note_for_client: str | None = None  # no column at all
+    # NOT NULL column; routes write it under the field name, clients may send the alias
+    off_delay_minutes: int | None = Field(default=None, alias="offDelay")
+
+
+def test_null_for_a_not_null_column_counts_as_not_sent():
+    update = _PlugUpdate(name=None, username="x")
+    assert update.model_dump(exclude_unset=True) == {"username": "x"}
+
+
+def test_null_for_a_nullable_column_clears_it():
+    update = _PlugUpdate(username=None)
+    assert update.model_dump(exclude_unset=True) == {"username": None}
+
+
+def test_null_for_a_field_with_no_column_is_left_alone():
+    assert _PlugUpdate(note_for_client=None).note_for_client is None
+
+
+def test_a_field_sent_under_its_alias_is_checked_by_its_column():
+    assert _PlugUpdate.model_validate({"offDelay": None}).model_dump(exclude_unset=True) == {}
+
+
+def test_leaving_a_field_out_is_not_null():
+    assert _PlugUpdate.model_validate({"username": "x"}).model_dump(exclude_unset=True) == {"username": "x"}