Jelajahi Sumber

Add ETA variable to print notification templates (#638)

  Add {eta} template variable showing wall-clock completion time
  (e.g. "15:53" or "3:53 PM") to print_start, print_progress, and
  queue_job_started notifications. Respects the user's time_format
  setting (12h/24h). The existing {estimated_time} variable continues
  to show duration ("1h 23m").
maziggy 6 bulan lalu
induk
melakukan
09c3c24111

+ 1 - 0
CHANGELOG.md

@@ -6,6 +6,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### New Features
 - **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
+- **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing `{estimated_time}` still shows duration ("1h 23m"). Requested by @SebSeifert.
 
 ### Improved
 - **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.

+ 6 - 3
backend/app/schemas/notification_template.py

@@ -27,7 +27,7 @@ class EventType(StrEnum):
 
 # Available variables for each event type
 EVENT_VARIABLES: dict[str, list[str]] = {
-    "print_start": ["printer", "filename", "estimated_time", "timestamp", "app_name"],
+    "print_start": ["printer", "filename", "estimated_time", "eta", "timestamp", "app_name"],
     "print_complete": [
         "printer",
         "filename",
@@ -61,7 +61,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "timestamp",
         "app_name",
     ],
-    "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"],
+    "print_progress": ["printer", "filename", "progress", "remaining_time", "eta", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
@@ -73,7 +73,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
     "queue_job_assigned": ["job_name", "printer", "target_model", "timestamp", "app_name"],
-    "queue_job_started": ["printer", "job_name", "estimated_time", "timestamp", "app_name"],
+    "queue_job_started": ["printer", "job_name", "estimated_time", "eta", "timestamp", "app_name"],
     "queue_job_waiting": ["job_name", "target_model", "waiting_reason", "timestamp", "app_name"],
     "queue_job_skipped": ["printer", "job_name", "reason", "timestamp", "app_name"],
     "queue_job_failed": ["printer", "job_name", "reason", "timestamp", "app_name"],
@@ -89,6 +89,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "estimated_time": "1h 23m",
+        "eta": "15:53",
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
@@ -130,6 +131,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "filename": "Benchy.3mf",
         "progress": "50",
         "remaining_time": "0h 41m",
+        "eta": "15:41",
         "timestamp": "2024-01-15 15:00",
         "app_name": "Bambuddy",
     },
@@ -205,6 +207,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "job_name": "Benchy.3mf",
         "estimated_time": "1h 23m",
+        "eta": "15:53",
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },

+ 24 - 1
backend/app/services/notification_service.py

@@ -5,7 +5,7 @@ import json
 import logging
 import re
 import smtplib
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from typing import Any
@@ -93,6 +93,21 @@ class NotificationService:
         result = re.sub(r"\{[a-z_]+\}", "", result)
         return result
 
+    async def _format_eta(self, seconds: int | None, db: AsyncSession) -> str:
+        """Format ETA as wall-clock time, respecting user's time_format setting."""
+        if not seconds or seconds <= 0:
+            return "Unknown"
+
+        from backend.app.api.routes.settings import get_setting
+
+        eta_time = datetime.now() + timedelta(seconds=seconds)
+        time_format = await get_setting(db, "time_format")
+
+        if time_format == "12h":
+            return eta_time.strftime("%I:%M %p").lstrip("0")
+        # Default to 24h for "24h", "system", or unset
+        return eta_time.strftime("%H:%M")
+
     def _format_duration(self, seconds: int | None) -> str:
         """Format duration in seconds to human-readable string."""
         if seconds is None:
@@ -678,11 +693,13 @@ class NotificationService:
                 logger.debug("Using mc_remaining_time from raw_data: %s", estimated_time)
 
         time_str = self._format_duration(estimated_time)
+        eta_str = await self._format_eta(estimated_time, db)
 
         variables = {
             "printer": printer_name,
             "filename": filename,
             "estimated_time": time_str,
+            "eta": eta_str,
         }
 
         # Extract image data for providers that support attachments (e.g. Pushover)
@@ -805,11 +822,14 @@ class NotificationService:
         if not providers:
             return
 
+        eta_str = await self._format_eta(remaining_time, db)
+
         variables = {
             "printer": printer_name,
             "filename": self._clean_filename(filename),
             "progress": str(progress),
             "remaining_time": self._format_duration(remaining_time) if remaining_time else "Unknown",
+            "eta": eta_str,
         }
 
         title, message = await self._build_message_from_template(db, "print_progress", variables)
@@ -1128,10 +1148,13 @@ class NotificationService:
         if not providers:
             return
 
+        eta_str = await self._format_eta(estimated_time, db)
+
         variables = {
             "job_name": job_name,
             "printer": printer_name,
             "estimated_time": self._format_duration(estimated_time),
+            "eta": eta_str,
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_started", variables)

+ 104 - 0
backend/tests/unit/services/test_notification_service.py

@@ -738,6 +738,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             # Need at least one provider to trigger message building
             mock_get.return_value = [mock_provider]
@@ -772,6 +773,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             # Need at least one provider to trigger message building
             mock_get.return_value = [mock_provider]
@@ -837,6 +839,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             mock_get.return_value = [mock_provider]
 
@@ -869,6 +872,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             mock_get.return_value = [mock_provider]
 
@@ -905,6 +909,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             mock_get.return_value = [mock_provider]
 
@@ -923,6 +928,105 @@ class TestNotificationVariableFallbacks:
             # Should use MQTT remaining_time
             assert captured_variables.get("estimated_time") == "30m"
 
+    @pytest.mark.asyncio
+    async def test_print_start_eta_calculated_from_estimated_time(self, service):
+        """Verify ETA is calculated as wall-clock time from estimated_time."""
+        mock_db = AsyncMock()
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_print_start(
+                printer_id=1,
+                printer_name="Test",
+                data={"subtask_name": "test"},
+                db=mock_db,
+                archive_data={"print_time_seconds": 3600},  # 1 hour
+            )
+
+            # ETA should be a time string in HH:MM format
+            eta = captured_variables.get("eta")
+            assert eta is not None
+            assert eta != "Unknown"
+            assert ":" in eta  # HH:MM format
+
+    @pytest.mark.asyncio
+    async def test_print_start_eta_unknown_when_no_time(self, service):
+        """Verify ETA shows 'Unknown' when no time data available."""
+        mock_db = AsyncMock()
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_print_start(
+                printer_id=1,
+                printer_name="Test",
+                data={"subtask_name": "test"},
+                db=mock_db,
+            )
+
+            assert captured_variables.get("eta") == "Unknown"
+
+    @pytest.mark.asyncio
+    async def test_print_start_eta_respects_12h_format(self, service):
+        """Verify ETA uses 12-hour format when time_format is '12h'."""
+        mock_db = AsyncMock()
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value="12h"),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_print_start(
+                printer_id=1,
+                printer_name="Test",
+                data={"subtask_name": "test"},
+                db=mock_db,
+                archive_data={"print_time_seconds": 3600},
+            )
+
+            eta = captured_variables.get("eta")
+            assert eta is not None
+            # 12h format should contain AM or PM
+            assert "AM" in eta or "PM" in eta
+
 
 class TestNotificationTemplates:
     """Tests for notification message template rendering."""