mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
fix(notifications): send the ntfy priority the dialog was collecting (issue #3139)
The per-event Priority header from #990 never reached ntfy. The dialog builds its rows from the provider's event toggles and stores the map under those names -- on_print_complete, on_print_failed -- while every sender is called with the bare event name, print_complete. The lookup missed for all 18 events the dialog offers, so every notification went out at the ntfy server's default with the configured priority sitting untouched in the database. Both ends looked healthy, which is why it shipped. The stored config held exactly what was set, and the tests were green because they called _send_ntfy directly with the prefixed name -- the one spelling the running system never produces. - notification_service.py: accept either spelling, bare first, so existing configs keep working and nothing needs migrating. - schemas/notification.py: document both key forms, and which one the UI writes. - tests: use the bare names, and add one that runs from a finished print through to the outgoing request. Without the fix it fails on a header dict holding only Title, which is the assertion that was missing. The daily digest is unchanged: send_digest sends with no event_type at all, and the dialog offers no priority for it -- it is one message for several events.
This commit is contained in:
@@ -31,6 +31,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
|
||||
|
||||
### Fixed
|
||||
- **ntfy per-event priorities were set, stored, and never sent (#3139, reported by @Thomansky)** — a per-event priority (#990) never reached ntfy: every notification arrived at the server's default, whatever the dialog showed. The two halves of the feature disagreed about the key. The dialog builds its rows from the provider's event toggles and stores the map under those names — `on_print_complete`, `on_print_failed` — while every sender is called with the bare event name, `print_complete`, so the lookup missed for all 18 events the dialog offers. It looked healthy from both ends: the stored config held exactly the priorities that were set, and the tests were green because they called the ntfy sender directly with the prefixed name, the one spelling the running system never produces. The lookup now accepts either spelling, so existing configurations keep working and nothing has to be migrated. The tests use the bare form, and one of them now runs from a finished print through to the outgoing request, which is the assertion that would have caught this. On upgrade, priorities that have been sitting in the database inertly start being honoured, so an event mapped to **Min** or **Low** will now arrive quieter than it did yesterday — that is the setting taking effect, not alerts going missing. The daily digest is unchanged and still goes out at the server default: it summarises several events at once, and the dialog offers no priority for it.
|
||||
- **Restoring a backup into a different version of Bambuddy failed, and the failure took the live database with it** — A backup carries the schema of the install that made it, and the PostgreSQL restore path does not use that schema: it drops every table, recreates them from the running process's ORM, and inserts the backup's columns into the result. Any NOT NULL column the running version has and the backup does not then arrives with nothing to put in it, and a model-side `default=` cannot help, because SQLAlchemy applies one to ORM and Core inserts and never to the raw `text()` SQL this import builds — `create_all` emits no DDL default for it either. A backup made after `user_wallets.currency` was dropped in #3123, restored onto an install that still had the column, died on `null value in column "currency"` with the drop already committed: the restore's first transaction wipes the database, so the install was left with an empty schema, the previous data gone, and the MFA key file already replaced by the backup's — whatever had survived would have been encrypted under a key that no longer matched. The import now fills such a column from the model's default (evaluating a callable one), leaves a column with a server default to the database rather than second-guessing a timestamp it is better placed to produce, and ignores a nullable one exactly as before. A column that genuinely cannot be filled is refused **before anything is touched** — before services are paused, before the key file is written, before the first `DROP` — and the same applies to a `bambuddy.db` that is not a readable SQLite database at all, which previously was discovered only by the import that had already dropped the tables. Backups now carry a `manifest.json` naming the version that wrote them, so the refusal reads "this backup was made by X, this install runs Y" rather than a list of column names; a backup taken before the manifest existed restores exactly as it did, with the version simply unknown. The refusal is an HTTP 400, and the frontend was dropping its body on the floor — a refused restore is an `HTTPException`, so its `{detail}` is not the `{success, message}` a successful one returns, and the modal raised an empty error toast — so the reason now reaches the operator who has to act on it. A SQLite install is unaffected throughout: it restores by copying the backup's pages, schema included, and migrates forward afterwards.
|
||||
- **Slicing a plate of many copies of one part could take the server down (#3135, reported by @TheUltimateC0der)** — the reporter sliced a plate of 25 small Gridfinity bins and Bambuddy was OOM-killed straight after the slice finished, taking the slice job with it. After every slice Bambuddy draws the plate thumbnail itself, because the slicer's command-line mode leaves it out, and it read the model with trimesh. trimesh mishandles the layout Bambu Studio and OrcaSlicer save projects in, where each copy is a reference into a shared model file: it read that file again for every reference and kept every copy of its triangles, so 25 copies of a part arrived as 625, and 25 bins of 10,000 triangles each became 6.4 million. Drawing that took 54 seconds and 8.4 GB, and it ran on the server's main loop, so the web UI and every printer connection stalled for as long. A project whose model file holds several parts had a second symptom: each placed part pulled in every other part from the same file, so its thumbnail drew parts in places they were never put. The plate is now read directly, each model file once and each part once however many times it is placed, and a large plate is simplified per part to a fixed triangle budget before its copies are laid out. The reporter's plate now renders in about 2.4 seconds at under 400 MB, and the heaviest real projects measured went from 48 seconds and 9.3 GB to 20 seconds and 0.8 GB. The thumbnail is drawn off the main loop, and a plate still too large after simplifying is left without one instead of risking the server.
|
||||
- **Moving a queued job from "Any P2S" to one P2S threw away its filament colour (#3133, reported by @bgrr74)** — a job queued for any printer of a model can print in a different filament from the one it was sliced with: brown in the 3MF, Bone White on the spool. Editing that job and switching it to a specific printer of the same model reset the choice, and the job went back to brown. The print dialog cleared overrides whenever the target model changed, and "Specific Printer" empties the target model, so the switch counted as a change. Clearing would not have been the whole problem anyway: in specific-printer mode the dialog matched trays against the 3MF's colours, never sent the override, and left the old one on the queue item, where it contradicted the trays the job would actually use. Now the override is kept through the switch, and the chosen printer's trays are matched against the requested filament, so Bone White picks the Bone White spool, and a printer without one shows the usual colour-mismatch warning for Bone White instead of quietly settling on brown. The job keeps the override on the queue item, so if the dialog could not resolve every tray, the scheduler's match at dispatch looks for the same colour. Moving the job to a different printer model, or picking a different plate, still clears the override, as before. A printer's queue card and badge no longer hide a job assigned to that printer because its override colour is not loaded. That check is for picking among printers of a model, which a job for one printer has already done. The same goes for a virtual printer's force-colour jobs sent to a specific printer, which the card used to hide the same way even though the scheduler dispatched them. Saving a virtual-printer job in the print dialog now keeps the PLA-variant pin (Basic, Matte or Silk) its force-colour overrides carry. Editing one queued for any printer of a model used to strip it.
|
||||
|
||||
@@ -301,9 +301,11 @@ class NtfyConfig(BaseModel):
|
||||
event_priorities: dict[str, int] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Per-event priority override. Keys are event names (e.g. 'on_print_failed'); "
|
||||
"values are ntfy priorities 1-5 (1=min, 2=low, 3=default, 4=high, 5=urgent). "
|
||||
"Events without an entry use ntfy's server-side default."
|
||||
"Per-event priority override. Keys are event names, either the provider's "
|
||||
"toggle column ('on_print_failed', what the UI writes) or the bare event "
|
||||
"name ('print_failed'); both are accepted. Values are ntfy priorities 1-5 "
|
||||
"(1=min, 2=low, 3=default, 4=high, 5=urgent). Events without an entry use "
|
||||
"ntfy's server-side default."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -398,9 +398,18 @@ class NotificationService:
|
||||
# Per-event Priority header (#990). Only set when the user has
|
||||
# explicitly mapped this event to a 1-5 value; otherwise fall through
|
||||
# to the ntfy server's default so existing setups stay unchanged.
|
||||
#
|
||||
# The map is keyed by the provider's toggle column ("on_print_failed"),
|
||||
# because that is what the dialog builds its rows from -- but every
|
||||
# sender is called with the bare event name ("print_failed"), so the
|
||||
# lookup used to miss for every real notification and hit only in tests
|
||||
# that called this method with the prefixed name (issue #3139). Both
|
||||
# spellings are accepted, which also leaves stored configs untouched.
|
||||
event_priorities = config.get("event_priorities") or {}
|
||||
if event_type and isinstance(event_priorities, dict):
|
||||
raw = event_priorities.get(event_type)
|
||||
if raw is None and not event_type.startswith("on_"):
|
||||
raw = event_priorities.get(f"on_{event_type}")
|
||||
try:
|
||||
priority = int(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.models.notification import NotificationProvider
|
||||
from backend.app.services.notification_service import NotificationService
|
||||
|
||||
|
||||
@@ -757,7 +758,15 @@ class TestDiscordProvider:
|
||||
|
||||
|
||||
class TestNtfyPriority:
|
||||
"""Per-event ntfy Priority header (#990)."""
|
||||
"""Per-event ntfy Priority header (#990).
|
||||
|
||||
The map is stored under the provider's toggle columns ("on_print_failed"),
|
||||
which is what the dialog builds its rows from, but every sender is called
|
||||
with the bare event name ("print_failed"). These tests use the bare form on
|
||||
purpose: the feature shipped broken because they used to pass the prefixed
|
||||
name straight into ``_send_ntfy``, the one spelling production never
|
||||
produces, so the lookup hit here and missed everywhere else (issue #3139).
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
@@ -783,12 +792,25 @@ class TestNtfyPriority:
|
||||
mock_client = self._mock_client(service)
|
||||
with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_client
|
||||
success, _ = await service._send_ntfy(config, "Title", "Body", event_type="on_print_failed")
|
||||
success, _ = await service._send_ntfy(config, "Title", "Body", event_type="print_failed")
|
||||
|
||||
assert success is True
|
||||
headers = mock_client.post.call_args.kwargs["headers"]
|
||||
assert headers.get("Priority") == "5"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priority_header_set_for_bare_key(self, service):
|
||||
"""A map keyed by the bare event name resolves too, so a config written
|
||||
by hand (or by any future caller that drops the prefix) still works."""
|
||||
config = {"topic": "bambuddy", "event_priorities": {"print_failed": 4}}
|
||||
mock_client = self._mock_client(service)
|
||||
with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_client
|
||||
await service._send_ntfy(config, "Title", "Body", event_type="print_failed")
|
||||
|
||||
headers = mock_client.post.call_args.kwargs["headers"]
|
||||
assert headers.get("Priority") == "4"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priority_header_omitted_for_unmapped_event(self, service):
|
||||
"""Unmapped event → no Priority header so ntfy uses its server default."""
|
||||
@@ -799,7 +821,7 @@ class TestNtfyPriority:
|
||||
mock_client = self._mock_client(service)
|
||||
with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_client
|
||||
await service._send_ntfy(config, "Title", "Body", event_type="on_print_complete")
|
||||
await service._send_ntfy(config, "Title", "Body", event_type="print_complete")
|
||||
|
||||
headers = mock_client.post.call_args.kwargs["headers"]
|
||||
assert "Priority" not in headers
|
||||
@@ -811,7 +833,7 @@ class TestNtfyPriority:
|
||||
mock_client = self._mock_client(service)
|
||||
with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_client
|
||||
await service._send_ntfy(config, "Title", "Body", event_type="on_print_failed")
|
||||
await service._send_ntfy(config, "Title", "Body", event_type="print_failed")
|
||||
|
||||
headers = mock_client.post.call_args.kwargs["headers"]
|
||||
assert "Priority" not in headers
|
||||
@@ -842,7 +864,7 @@ class TestNtfyPriority:
|
||||
mock_client = self._mock_client(service)
|
||||
with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_client
|
||||
await service._send_ntfy(config, "Title", "Body", event_type="on_print_failed")
|
||||
await service._send_ntfy(config, "Title", "Body", event_type="print_failed")
|
||||
|
||||
headers = mock_client.post.call_args.kwargs["headers"]
|
||||
assert "Priority" not in headers, f"unexpected header for bad value {bad!r}"
|
||||
@@ -862,12 +884,50 @@ class TestNtfyPriority:
|
||||
"Title",
|
||||
"Body",
|
||||
image_data=b"\xff\xd8\xff\xe0fake-jpeg",
|
||||
event_type="on_first_layer_complete",
|
||||
event_type="first_layer_complete",
|
||||
)
|
||||
|
||||
headers = mock_client.put.call_args.kwargs["headers"]
|
||||
assert headers.get("Priority") == "4"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priority_reaches_ntfy_from_a_real_event(self, service):
|
||||
"""The wiring, end to end: a finished print, a provider configured the
|
||||
way the dialog writes it, and the header on the request that leaves.
|
||||
|
||||
Everything above calls ``_send_ntfy`` directly, so none of it can see a
|
||||
caller passing a key shape the lookup does not understand -- which is
|
||||
exactly how #3139 shipped green.
|
||||
"""
|
||||
provider = NotificationProvider(
|
||||
id=1,
|
||||
name="ntfy",
|
||||
provider_type="ntfy",
|
||||
enabled=True,
|
||||
config=json.dumps({"topic": "bambuddy", "event_priorities": {"on_print_complete": 5}}),
|
||||
quiet_hours_enabled=False,
|
||||
daily_digest_enabled=False,
|
||||
)
|
||||
mock_client = self._mock_client(service)
|
||||
mock_db = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get,
|
||||
patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_providers,
|
||||
patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_template,
|
||||
patch.object(service, "_update_provider_status", new_callable=AsyncMock),
|
||||
patch.object(service, "_log_notification", new_callable=AsyncMock),
|
||||
):
|
||||
mock_get.return_value = mock_client
|
||||
mock_providers.return_value = [provider]
|
||||
mock_template.return_value = ("Print complete", "Benchy finished")
|
||||
|
||||
await service.on_print_complete(1, "X1C", "completed", {"filename": "benchy.3mf"}, mock_db)
|
||||
|
||||
mock_client.post.assert_called_once()
|
||||
headers = mock_client.post.call_args.kwargs["headers"]
|
||||
assert headers.get("Priority") == "5"
|
||||
|
||||
|
||||
class TestHomeAssistantProvider:
|
||||
"""Tests for Home Assistant notification provider."""
|
||||
|
||||
Reference in New Issue
Block a user