Explorar el Código

Post work PR #693

  Add tests, docs, and ruff fixes for per-user email notifications

  - Fix missing timezone import in email_service.py (F821)
  - Fix unused lambda arg in main.py asyncio done_callback (ARG005)
  - Fix E302 blank line spacing for mark_printer_stopped_by_user
  - Fix F821/UP037 forward reference in user_email_pref model
  - Fix SettingsPage test for duplicate "Notifications" text
  - Add backend unit tests for permissions, schemas, and templates
  - Add backend integration tests for user-notifications API
  - Add frontend tests for NotificationsPage
  - Add user_email_pref model import to test conftest
  - Update CHANGELOG and README
maziggy hace 2 meses
padre
commit
a6d307d739

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.3b1] - Unreleased
 ## [0.2.3b1] - Unreleased
 
 
 ### New Features
 ### New Features
+- **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
 
 
 ### Fixed
 ### Fixed
 - **Webhook Notifications Missing Camera Snapshot** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Webhook notification providers did not include camera snapshots (e.g. from First Layer Complete notifications), even though providers like Telegram, Pushover, ntfy, and Discord already attached them. The webhook payload now includes a base64-encoded `image` field when a snapshot is available (generic format only, not Slack format). Reported by @Arn0uDz.
 - **Webhook Notifications Missing Camera Snapshot** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Webhook notification providers did not include camera snapshots (e.g. from First Layer Complete notifications), even though providers like Telegram, Pushover, ntfy, and Discord already attached them. The webhook payload now includes a base64-encoded `image` field when a snapshot is available (generic format only, not Slack format). Reported by @Arn0uDz.

+ 1 - 0
README.md

@@ -213,6 +213,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Admin creates users with email — system sends secure random password automatically
 - Admin creates users with email — system sends secure random password automatically
 - Users can reset their own password from the login screen (no admin needed)
 - Users can reset their own password from the login screen (no admin needed)
 - Customizable email templates (welcome email, password reset)
 - Customizable email templates (welcome email, password reset)
+- **Per-user email notifications** — Users receive email alerts for their own print jobs (start, complete, failed, stopped) with individual toggle controls
 
 
 </td>
 </td>
 </tr>
 </tr>

+ 4 - 3
backend/app/main.py

@@ -376,6 +376,7 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
         stored_ams_mapping = _print_ams_mappings.get(archive_id)
         stored_ams_mapping = _print_ams_mappings.get(archive_id)
     return stored_ams_mapping
     return stored_ams_mapping
 
 
+
 def mark_printer_stopped_by_user(printer_id: int) -> None:
 def mark_printer_stopped_by_user(printer_id: int) -> None:
     """Mark that the active print on this printer was stopped by the user from the queue UI.
     """Mark that the active print on this printer was stopped by the user from the queue UI.
 
 
@@ -386,6 +387,7 @@ def mark_printer_stopped_by_user(printer_id: int) -> None:
     _user_stopped_printers.add(printer_id)
     _user_stopped_printers.add(printer_id)
     logging.getLogger(__name__).info("Marked printer %s as user-stopped from queue", printer_id)
     logging.getLogger(__name__).info("Marked printer %s as user-stopped from queue", printer_id)
 
 
+
 _last_status_broadcast: dict[int, str] = {}
 _last_status_broadcast: dict[int, str] = {}
 # Track printers where we've updated nozzle_count
 # Track printers where we've updated nozzle_count
 _nozzle_count_updated: set[int] = set()
 _nozzle_count_updated: set[int] = set()
@@ -2216,8 +2218,7 @@ async def on_print_complete(printer_id: int, data: dict):
     _raw_status = data.get("status", "completed")
     _raw_status = data.get("status", "completed")
     if printer_id in _user_stopped_printers and _raw_status in ("failed", "aborted"):
     if printer_id in _user_stopped_printers and _raw_status in ("failed", "aborted"):
         logger.info(
         logger.info(
-            "[CALLBACK] Overriding status '%s' -> 'cancelled' for printer %s "
-            "(print was stopped from queue by user)",
+            "[CALLBACK] Overriding status '%s' -> 'cancelled' for printer %s (print was stopped from queue by user)",
             _raw_status,
             _raw_status,
             printer_id,
             printer_id,
         )
         )
@@ -2654,7 +2655,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True)
                 logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True)
 
 
         task = asyncio.create_task(_notify_no_archive())
         task = asyncio.create_task(_notify_no_archive())
-        task.add_done_callback(lambda t: None)
+        task.add_done_callback(lambda _t: None)
         return
         return
 
 
     log_timing("Archive lookup")
     log_timing("Archive lookup")

+ 7 - 1
backend/app/models/user_email_pref.py

@@ -1,12 +1,18 @@
 """User email notification preference model."""
 """User email notification preference model."""
 
 
+from __future__ import annotations
+
 from datetime import datetime
 from datetime import datetime
+from typing import TYPE_CHECKING
 
 
 from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func
 from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
 
 
+if TYPE_CHECKING:
+    from backend.app.models.user import User
+
 
 
 class UserEmailPreference(Base):
 class UserEmailPreference(Base):
     """Stores per-user email notification preferences for their own print jobs."""
     """Stores per-user email notification preferences for their own print jobs."""
@@ -28,4 +34,4 @@ class UserEmailPreference(Base):
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
     # Relationship
     # Relationship
-    user: Mapped["User"] = relationship(back_populates="email_preferences")
+    user: Mapped[User] = relationship(back_populates="email_preferences")

+ 1 - 1
backend/app/services/email_service.py

@@ -8,7 +8,7 @@ import re
 import secrets
 import secrets
 import smtplib
 import smtplib
 import string
 import string
-from datetime import datetime
+from datetime import datetime, timezone
 from email.mime.multipart import MIMEMultipart
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from email.mime.text import MIMEText
 from typing import Any
 from typing import Any

+ 1 - 0
backend/tests/conftest.py

@@ -88,6 +88,7 @@ async def test_engine():
         spool_catalog,
         spool_catalog,
         spool_usage_history,
         spool_usage_history,
         user,
         user,
+        user_email_pref,
         virtual_printer,
         virtual_printer,
     )
     )
 
 

+ 80 - 0
backend/tests/integration/test_user_notifications_api.py

@@ -0,0 +1,80 @@
+"""Integration tests for User Notifications API endpoints.
+
+Tests the full request/response cycle for /api/v1/user-notifications/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestUserNotificationsAPI:
+    """Integration tests for /api/v1/user-notifications/ endpoints."""
+
+    # ========================================================================
+    # GET /preferences — no auth
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_preferences_returns_defaults_when_no_auth(self, async_client: AsyncClient):
+        """Without auth, GET should return all-enabled defaults."""
+        response = await async_client.get("/api/v1/user-notifications/preferences")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["notify_print_start"] is True
+        assert data["notify_print_complete"] is True
+        assert data["notify_print_failed"] is True
+        assert data["notify_print_stopped"] is True
+
+    # ========================================================================
+    # PUT /preferences — no auth
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_preferences_fails_without_auth(self, async_client: AsyncClient):
+        """Without auth enabled, PUT should return 400 (no user context)."""
+        data = {
+            "notify_print_start": False,
+            "notify_print_complete": True,
+            "notify_print_failed": True,
+            "notify_print_stopped": False,
+        }
+
+        response = await async_client.put("/api/v1/user-notifications/preferences", json=data)
+
+        assert response.status_code == 400
+        assert "Authentication must be enabled" in response.json()["detail"]
+
+    # ========================================================================
+    # Schema validation
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_preferences_rejects_missing_fields(self, async_client: AsyncClient):
+        """PUT should reject requests missing required boolean fields."""
+        data = {
+            "notify_print_start": True,
+            # missing other fields
+        }
+
+        response = await async_client.put("/api/v1/user-notifications/preferences", json=data)
+
+        assert response.status_code == 422  # Pydantic validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_preferences_rejects_invalid_type(self, async_client: AsyncClient):
+        """PUT should reject values that cannot be coerced to boolean."""
+        data = {
+            "notify_print_start": [1, 2, 3],
+            "notify_print_complete": True,
+            "notify_print_failed": True,
+            "notify_print_stopped": True,
+        }
+
+        response = await async_client.put("/api/v1/user-notifications/preferences", json=data)
+
+        assert response.status_code == 422

+ 123 - 0
backend/tests/unit/test_user_notifications.py

@@ -0,0 +1,123 @@
+"""Tests for user email notification preferences and permissions."""
+
+from backend.app.core.permissions import (
+    ALL_PERMISSIONS,
+    DEFAULT_GROUPS,
+    PERMISSION_CATEGORIES,
+    Permission,
+)
+from backend.app.schemas.user_notifications import (
+    UserEmailPreferenceResponse,
+    UserEmailPreferenceUpdate,
+)
+
+
+class TestNotificationsUserEmailPermission:
+    """Test the NOTIFICATIONS_USER_EMAIL permission integration."""
+
+    def test_permission_exists(self):
+        """notifications:user_email permission should exist in the enum."""
+        assert hasattr(Permission, "NOTIFICATIONS_USER_EMAIL")
+        assert Permission.NOTIFICATIONS_USER_EMAIL == "notifications:user_email"
+
+    def test_permission_in_all_permissions(self):
+        """notifications:user_email should be in ALL_PERMISSIONS list."""
+        assert "notifications:user_email" in ALL_PERMISSIONS
+
+    def test_permission_in_notifications_category(self):
+        """notifications:user_email should be in the Notifications permission category."""
+        notifications_perms = PERMISSION_CATEGORIES["Notifications"]
+        assert Permission.NOTIFICATIONS_USER_EMAIL in notifications_perms
+
+    def test_administrators_have_permission(self):
+        """Administrators should have notifications:user_email via ALL_PERMISSIONS."""
+        admins = DEFAULT_GROUPS["Administrators"]
+        assert "notifications:user_email" in admins["permissions"]
+
+    def test_operators_have_permission(self):
+        """Operators should have notifications:user_email for managing their own preferences."""
+        operators = DEFAULT_GROUPS["Operators"]
+        assert "notifications:user_email" in operators["permissions"]
+
+    def test_viewers_do_not_have_permission(self):
+        """Viewers (read-only) should not have notifications:user_email."""
+        viewers = DEFAULT_GROUPS["Viewers"]
+        assert "notifications:user_email" not in viewers["permissions"]
+
+    def test_permission_separate_from_notifications_read(self):
+        """user_email and read should be distinct permissions."""
+        assert Permission.NOTIFICATIONS_USER_EMAIL != Permission.NOTIFICATIONS_READ
+        assert Permission.NOTIFICATIONS_USER_EMAIL.value != Permission.NOTIFICATIONS_READ.value
+
+
+class TestUserEmailPreferenceSchemas:
+    """Test the user email preference Pydantic schemas."""
+
+    def test_response_schema_defaults(self):
+        """Response schema should accept all four boolean fields."""
+        resp = UserEmailPreferenceResponse(
+            notify_print_start=True,
+            notify_print_complete=True,
+            notify_print_failed=True,
+            notify_print_stopped=True,
+        )
+        assert resp.notify_print_start is True
+        assert resp.notify_print_complete is True
+        assert resp.notify_print_failed is True
+        assert resp.notify_print_stopped is True
+
+    def test_response_schema_all_disabled(self):
+        """Response schema should handle all-disabled preferences."""
+        resp = UserEmailPreferenceResponse(
+            notify_print_start=False,
+            notify_print_complete=False,
+            notify_print_failed=False,
+            notify_print_stopped=False,
+        )
+        assert resp.notify_print_start is False
+        assert resp.notify_print_complete is False
+        assert resp.notify_print_failed is False
+        assert resp.notify_print_stopped is False
+
+    def test_update_schema_accepts_mixed(self):
+        """Update schema should accept a mix of enabled/disabled."""
+        update = UserEmailPreferenceUpdate(
+            notify_print_start=True,
+            notify_print_complete=False,
+            notify_print_failed=True,
+            notify_print_stopped=False,
+        )
+        assert update.notify_print_start is True
+        assert update.notify_print_complete is False
+        assert update.notify_print_failed is True
+        assert update.notify_print_stopped is False
+
+    def test_response_schema_from_attributes(self):
+        """Response schema should support from_attributes (ORM mode)."""
+        assert UserEmailPreferenceResponse.model_config.get("from_attributes") is True
+
+
+class TestNotificationTemplateTypes:
+    """Test that user print notification template types are registered."""
+
+    def test_user_print_template_types_exist(self):
+        """All four user print email template types should be in EVENT_NAMES."""
+        from backend.app.api.routes.notification_templates import EVENT_NAMES
+
+        expected_types = [
+            "user_print_start",
+            "user_print_complete",
+            "user_print_failed",
+            "user_print_stopped",
+        ]
+        for event_type in expected_types:
+            assert event_type in EVENT_NAMES, f"{event_type} not in EVENT_NAMES"
+
+    def test_user_print_template_display_names(self):
+        """User print template display names should be descriptive."""
+        from backend.app.api.routes.notification_templates import EVENT_NAMES
+
+        assert EVENT_NAMES["user_print_start"] == "User Print Started Email"
+        assert EVENT_NAMES["user_print_complete"] == "User Print Completed Email"
+        assert EVENT_NAMES["user_print_failed"] == "User Print Failed Email"
+        assert EVENT_NAMES["user_print_stopped"] == "User Print Stopped Email"

+ 136 - 0
frontend/src/__tests__/pages/NotificationsPage.test.tsx

@@ -0,0 +1,136 @@
+/**
+ * Tests for the NotificationsPage component.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { NotificationsPage } from '../../pages/NotificationsPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPreferences = {
+  notify_print_start: true,
+  notify_print_complete: true,
+  notify_print_failed: true,
+  notify_print_stopped: true,
+};
+
+const mockAdvancedAuthEnabled = {
+  advanced_auth_enabled: true,
+  smtp_configured: true,
+};
+
+const mockSettingsWithNotifications = {
+  auto_archive: true,
+  user_notifications_enabled: true,
+};
+
+describe('NotificationsPage', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/auth/advanced-auth/status', () => {
+        return HttpResponse.json(mockAdvancedAuthEnabled);
+      }),
+      http.get('/api/v1/user-notifications/preferences', () => {
+        return HttpResponse.json(mockPreferences);
+      }),
+      http.put('/api/v1/user-notifications/preferences', async ({ request }) => {
+        const body = await request.json();
+        return HttpResponse.json(body);
+      }),
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json(mockSettingsWithNotifications);
+      }),
+      http.get('*/api/v1/auth/status', () => {
+        return HttpResponse.json({ auth_enabled: false, requires_setup: false });
+      }),
+      http.get('/api/v1/auth/me', () => {
+        return HttpResponse.json({
+          id: 1,
+          username: 'testuser',
+          email: 'test@example.com',
+          role: 'admin',
+          is_active: true,
+          is_admin: true,
+          groups: [{ id: 1, name: 'Administrators' }],
+          permissions: [],
+          created_at: '2024-01-01T00:00:00Z',
+        });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the page heading', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Notifications')).toBeInTheDocument();
+      });
+    });
+
+    it('renders all four notification toggle options', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Print Job Starts')).toBeInTheDocument();
+        expect(screen.getByText('Print Job Finishes')).toBeInTheDocument();
+        expect(screen.getByText('Print Errors')).toBeInTheDocument();
+        expect(screen.getByText('Print Job Stops')).toBeInTheDocument();
+      });
+    });
+
+    it('renders four toggle switches', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        const switches = screen.getAllByRole('switch');
+        expect(switches).toHaveLength(4);
+      });
+    });
+
+    it('renders save button', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+      });
+    });
+
+    it('shows loading spinner initially', () => {
+      render(<NotificationsPage />);
+      expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+    });
+  });
+
+  describe('toggle interaction', () => {
+    it('toggles switch state when clicked', async () => {
+      const user = userEvent.setup();
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByRole('switch')).toHaveLength(4);
+      });
+
+      const switches = screen.getAllByRole('switch');
+      // All should start checked (matching mock preferences)
+      expect(switches[0]).toHaveAttribute('aria-checked', 'true');
+
+      await user.click(switches[0]); // Toggle print start off
+
+      expect(switches[0]).toHaveAttribute('aria-checked', 'false');
+    });
+  });
+
+  describe('redirect behavior', () => {
+    it('does not redirect when advanced auth is enabled and notifications are enabled', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Notifications')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 6 - 3
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -86,7 +86,7 @@ describe('SettingsPage', () => {
         // Use getAllByText since "General" appears both as tab and section heading
         // Use getAllByText since "General" appears both as tab and section heading
         expect(screen.getAllByText('General').length).toBeGreaterThan(0);
         expect(screen.getAllByText('General').length).toBeGreaterThan(0);
         expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
         expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
-        expect(screen.getByText('Notifications')).toBeInTheDocument();
+        expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
         expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
         expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
         expect(screen.getByText('Network')).toBeInTheDocument();
         expect(screen.getByText('Network')).toBeInTheDocument();
         expect(screen.getByText('API Keys')).toBeInTheDocument();
         expect(screen.getByText('API Keys')).toBeInTheDocument();
@@ -193,10 +193,13 @@ describe('SettingsPage', () => {
       render(<SettingsPage />);
       render(<SettingsPage />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Notifications')).toBeInTheDocument();
+        expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
       });
       });
 
 
-      await user.click(screen.getByText('Notifications'));
+      // Click the tab button (not the mobile dropdown option)
+      const notificationButtons = screen.getAllByText('Notifications');
+      const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0];
+      await user.click(tabButton);
 
 
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText('Add Provider')).toBeInTheDocument();
         expect(screen.getByText('Add Provider')).toBeInTheDocument();

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DKZ25Iex.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-Dq3gZWq6.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DOxM2lYW.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BConTWeP.css">
+    <script type="module" crossorigin src="/assets/index-DKZ25Iex.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Dq3gZWq6.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio