Added template system to notification module.

This commit is contained in:
maziggy
2025-12-08 15:23:17 +00:00
parent 1a17b547dc
commit 29aadfb774
18 changed files with 9962 additions and 8983 deletions
+216 -155
View File
@@ -1,186 +1,247 @@
# Notifications Feature Implementation Plan
# Notification Templates Management System
## Overview
Add push notifications for print events (start, complete, fail) with support for multiple notification providers.
## Supported Providers (Initial Release)
1. **CallMeBot/WhatsApp** - Free, uses HTTP API with phone number + API key
2. **ntfy** - Self-hosted or ntfy.sh, simple HTTP POST
3. **Pushover** - Commercial ($5 one-time), HTTP API with user key + app token
4. **Telegram** - Free bot API, requires bot token + chat ID
5. **Email (SMTP)** - Universal fallback
Replace hardcoded notification messages with a flexible template system that allows users to customize notification content per event type, with provider-specific formatting support.
## Database Design
---
## Data Model
### New Table: `notification_templates`
### New Table: `notification_providers`
```sql
CREATE TABLE notification_providers (
CREATE TABLE notification_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL, -- User-defined name ("My WhatsApp")
provider_type TEXT NOT NULL, -- "callmebot", "ntfy", "pushover", "telegram", "email"
enabled BOOLEAN DEFAULT true,
-- Provider-specific config (JSON or individual fields)
config TEXT NOT NULL, -- JSON: {"phone": "+1234", "apikey": "xxx"}
-- Event triggers (which events send notifications)
on_print_start BOOLEAN DEFAULT false,
on_print_complete BOOLEAN DEFAULT true,
on_print_failed BOOLEAN DEFAULT true,
-- Optional: Link to specific printer (NULL = all printers)
printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
-- Timestamps
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
event_type VARCHAR(50) NOT NULL, -- print_start, print_complete, etc.
name VARCHAR(100) NOT NULL, -- User-friendly name
title_template TEXT NOT NULL, -- Template for notification title
body_template TEXT NOT NULL, -- Template for notification body
is_default BOOLEAN DEFAULT 0, -- System default (non-deletable)
created_at DATETIME,
updated_at DATETIME
);
```
### Config JSON Structure by Provider
```python
# CallMeBot/WhatsApp
{"phone": "+1234567890", "apikey": "123456"}
**Event Types:**
- `print_start`
- `print_complete`
- `print_failed`
- `print_stopped`
- `print_progress`
- `printer_offline`
- `printer_error`
- `filament_low`
- `maintenance_due`
- `test` (for test notifications)
# ntfy
{"server": "https://ntfy.sh", "topic": "my-printer", "auth_token": "optional"}
---
# Pushover
{"user_key": "xxx", "app_token": "yyy", "priority": 0}
## Template Variables
# Telegram
{"bot_token": "123:ABC", "chat_id": "12345678"}
Variables use `{variable_name}` syntax (Python format strings).
# Email (SMTP)
{"smtp_server": "smtp.gmail.com", "smtp_port": 587, "username": "x", "password": "y", "from_email": "x@gmail.com", "to_email": "dest@example.com"}
### Per-Event Variables:
| Event | Variables |
|-------|-----------|
| `print_start` | `{printer}`, `{filename}`, `{estimated_time}` |
| `print_complete` | `{printer}`, `{filename}`, `{duration}`, `{filament_grams}` |
| `print_failed` | `{printer}`, `{filename}`, `{duration}`, `{reason}` |
| `print_stopped` | `{printer}`, `{filename}`, `{duration}` |
| `print_progress` | `{printer}`, `{filename}`, `{progress}`, `{remaining_time}` |
| `printer_offline` | `{printer}` |
| `printer_error` | `{printer}`, `{error_type}`, `{error_detail}` |
| `filament_low` | `{printer}`, `{slot}`, `{remaining_percent}`, `{color}` |
| `maintenance_due` | `{printer}`, `{items}` (formatted list) |
| `test` | `{app_name}` |
### Common Variables (all events):
- `{timestamp}` - Current date/time
- `{app_name}` - "BambuTrack"
---
## Default Templates
Pre-seeded templates for each event (marked `is_default=True`):
```
print_start:
title: "Print Started"
body: "{printer}: {filename}\nEstimated: {estimated_time}"
print_complete:
title: "Print Completed"
body: "{printer}: {filename}\nTime: {duration}\nFilament: {filament_grams}g"
print_failed:
title: "Print Failed"
body: "{printer}: {filename}\nTime: {duration}\nReason: {reason}"
print_stopped:
title: "Print Stopped"
body: "{printer}: {filename}\nTime: {duration}"
print_progress:
title: "Print {progress}% Complete"
body: "{printer}: {filename}\nRemaining: {remaining_time}"
printer_offline:
title: "Printer Offline"
body: "{printer} has disconnected"
printer_error:
title: "Printer Error: {error_type}"
body: "{printer}\n{error_detail}"
filament_low:
title: "Filament Low"
body: "{printer}: Slot {slot} at {remaining_percent}%"
maintenance_due:
title: "Maintenance Due"
body: "{printer}:\n{items}"
test:
title: "BambuTrack Test"
body: "This is a test notification. If you see this, notifications are working!"
```
## Backend Implementation
---
### 1. Model: `backend/app/models/notification.py`
- SQLAlchemy model for `notification_providers` table
- Relationship to Printer (optional, nullable)
## Provider-Specific Formatting
### 2. Schema: `backend/app/schemas/notification.py`
- `NotificationProviderBase` - Common fields
- `NotificationProviderCreate` - For creating new providers
- `NotificationProviderUpdate` - For partial updates
- `NotificationProviderResponse` - API response with id/timestamps
- `NotificationTestRequest` - For testing notifications
The template system supports provider-specific formatting via a simple approach:
### 3. Service: `backend/app/services/notification_service.py`
Core notification dispatcher with provider implementations:
1. **Plain text** (default) - Used for CallMeBot, ntfy, Pushover, Email
2. **Markdown** - Automatically applied for Telegram (wrap title in `*bold*`)
```python
class NotificationService:
async def send_notification(self, provider: NotificationProvider, event: str, data: dict) -> bool
async def on_print_start(self, printer_id: int, data: dict, db: AsyncSession)
async def on_print_complete(self, printer_id: int, status: str, data: dict, db: AsyncSession)
The notification service will:
- Render the template with variables
- Apply provider-specific formatting when sending
# Provider-specific methods
async def _send_callmebot(self, config: dict, message: str) -> bool
async def _send_ntfy(self, config: dict, title: str, message: str) -> bool
async def _send_pushover(self, config: dict, title: str, message: str) -> bool
async def _send_telegram(self, config: dict, message: str) -> bool
async def _send_email(self, config: dict, subject: str, body: str) -> bool
---
## Implementation Steps
### Backend
1. **Create model** `backend/app/models/notification_template.py`
- NotificationTemplate SQLAlchemy model
2. **Create schemas** `backend/app/schemas/notification_template.py`
- NotificationTemplateCreate, Update, Response
- TemplateVariables (documentation of available vars per event)
3. **Add migration** in `backend/app/core/database.py`
- Create table if not exists
- Seed default templates
4. **Create API routes** `backend/app/api/routes/notification_templates.py`
- `GET /api/v1/notification-templates` - List all templates
- `GET /api/v1/notification-templates/{id}` - Get single template
- `PUT /api/v1/notification-templates/{id}` - Update template
- `POST /api/v1/notification-templates/{id}/reset` - Reset to default
- `GET /api/v1/notification-templates/variables` - List available variables per event
- `POST /api/v1/notification-templates/preview` - Preview template with sample data
5. **Update notification service** `backend/app/services/notification_service.py`
- Load templates from database
- Render templates with variables
- Remove hardcoded message builders
6. **Register routes** in `backend/app/main.py`
### Frontend
7. **Add API client methods** `frontend/src/api/client.ts`
- getNotificationTemplates, updateNotificationTemplate, etc.
8. **Create template editor component** `frontend/src/components/NotificationTemplateEditor.tsx`
- Template editing UI with variable insertion buttons
- Live preview with sample data
- Reset to default button
9. **Update SettingsPage** `frontend/src/pages/SettingsPage.tsx`
- Add "Templates" sub-section in Notifications tab
- List all templates with edit capability
---
## UI Design
### Templates Section (in Settings > Notifications)
```
+--------------------------------------------------+
| Message Templates |
| Customize notification messages for each event |
+--------------------------------------------------+
| |
| +----------------+ +----------------+ |
| | Print Started | | Print Complete | ... |
| | "Print Started"| | "Print Compl..." | |
| | [Edit] | | [Edit] | |
| +----------------+ +----------------+ |
| |
+--------------------------------------------------+
```
### 4. Routes: `backend/app/api/routes/notifications.py`
### Template Editor Modal
```
GET /notifications/ - List all providers
POST /notifications/ - Create provider
GET /notifications/{id} - Get provider details
PATCH /notifications/{id} - Update provider
DELETE /notifications/{id} - Delete provider
POST /notifications/{id}/test - Send test notification
POST /notifications/test-config - Test config before saving
+--------------------------------------------------+
| Edit Template: Print Complete [X] |
+--------------------------------------------------+
| Title: |
| [Print Completed_________________________] |
| |
| Body: |
| +----------------------------------------------+ |
| | {printer}: {filename} | |
| | Time: {duration} | |
| | Filament: {filament_grams}g | |
| +----------------------------------------------+ |
| |
| Available Variables: |
| [+printer] [+filename] [+duration] [+filament] |
| |
| Preview: |
| +----------------------------------------------+ |
| | Title: Print Completed | |
| | Body: Bambu X1C: Benchy.3mf | |
| | Time: 1h 23m | |
| | Filament: 15.2g | |
| +----------------------------------------------+ |
| |
| [Reset to Default] [Cancel] [Save] |
+--------------------------------------------------+
```
### 5. Integration in `main.py`
Add calls to notification service in existing event handlers:
- `on_print_start()` - After smart_plug_manager call (line ~244)
- `on_print_complete()` - After archive update (line ~580)
---
## Frontend Implementation
## File Changes Summary
### 1. API Client: `frontend/src/api/client.ts`
Add types and API methods for notification providers.
| File | Action |
|------|--------|
| `backend/app/models/notification_template.py` | Create |
| `backend/app/schemas/notification_template.py` | Create |
| `backend/app/api/routes/notification_templates.py` | Create |
| `backend/app/core/database.py` | Modify (add migration + seeding) |
| `backend/app/models/__init__.py` | Modify (export new model) |
| `backend/app/services/notification_service.py` | Modify (use templates) |
| `backend/app/main.py` | Modify (register routes) |
| `frontend/src/api/client.ts` | Modify (add API methods + types) |
| `frontend/src/components/NotificationTemplateEditor.tsx` | Create |
| `frontend/src/pages/SettingsPage.tsx` | Modify (add templates section) |
### 2. Components
- `NotificationProviderCard.tsx` - Display single provider with enable/disable toggle
- `AddNotificationModal.tsx` - Modal for adding/editing providers with provider-specific forms
---
### 3. Settings Page Integration
Add "Notifications" section in SettingsPage.tsx (similar to Smart Plugs section):
- List of configured providers
- Add button
- Per-provider enable/disable
- Test button
- Event toggles (start/complete/failed)
## Notes
## Message Templates
### Print Started
```
🖨️ Print Started
{printer_name}: {filename}
Estimated time: {est_time}
```
### Print Completed
```
✅ Print Completed
{printer_name}: {filename}
Time: {actual_time}
Filament: {filament_used}g
```
### Print Failed
```
❌ Print Failed
{printer_name}: {filename}
Status: {failure_reason}
Progress: {progress}%
```
## Implementation Order
### Phase 1: Backend Core
1. Create notification model with migrations
2. Create notification schema
3. Create notification service with all 5 providers
4. Create notification routes (CRUD + test)
5. Register routes in main.py
6. Integrate into print event handlers
### Phase 2: Frontend
7. Add API types and methods
8. Create NotificationProviderCard component
9. Create AddNotificationModal component
10. Add Notifications section to SettingsPage
### Phase 3: Testing & Polish
11. Test each provider
12. Add error handling and logging
13. Handle network failures gracefully (don't block print events)
## Technical Notes
### Async HTTP Requests
Use `httpx` (already available) for async HTTP calls to notification APIs.
### Error Handling
- Notifications should NEVER block print events
- Log failures but continue processing
- Store last_error and last_success timestamps for UI feedback
### Security
- Store credentials in database (SQLite file already contains access codes)
- Consider encryption for sensitive fields in future
### Rate Limiting
- Debounce rapid events (don't spam on quick start/stop cycles)
- Consider per-provider rate limits
## Questions for User
None - proceeding with the 5 providers as discussed.
- Default templates cannot be deleted, only modified and reset
- Templates are language-agnostic (user writes in their preferred language)
- The existing `notification_language` setting can be removed later (templates replace i18n)
- Variables that are unavailable for an event will render as empty string
- Template rendering uses safe formatting (missing vars don't crash)
@@ -0,0 +1,148 @@
"""API routes for notification template management."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import get_db
from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
from backend.app.schemas.notification_template import (
NotificationTemplateResponse,
NotificationTemplateUpdate,
EventVariablesResponse,
TemplatePreviewRequest,
TemplatePreviewResponse,
EVENT_VARIABLES,
SAMPLE_DATA,
)
from backend.app.services.notification_service import notification_service
router = APIRouter(prefix="/notification-templates", tags=["notification-templates"])
# Event type display names
EVENT_NAMES = {
"print_start": "Print Started",
"print_complete": "Print Completed",
"print_failed": "Print Failed",
"print_stopped": "Print Stopped",
"print_progress": "Print Progress",
"printer_offline": "Printer Offline",
"printer_error": "Printer Error",
"filament_low": "Filament Low",
"maintenance_due": "Maintenance Due",
"test": "Test Notification",
}
@router.get("", response_model=list[NotificationTemplateResponse])
async def get_templates(db: AsyncSession = Depends(get_db)):
"""Get all notification templates."""
result = await db.execute(
select(NotificationTemplate).order_by(NotificationTemplate.id)
)
return result.scalars().all()
@router.get("/variables", response_model=list[EventVariablesResponse])
async def get_variables():
"""Get available variables for each event type."""
return [
EventVariablesResponse(
event_type=event_type,
event_name=EVENT_NAMES.get(event_type, event_type),
variables=variables,
)
for event_type, variables in EVENT_VARIABLES.items()
]
@router.get("/{template_id}", response_model=NotificationTemplateResponse)
async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
"""Get a single notification template."""
result = await db.execute(
select(NotificationTemplate).where(NotificationTemplate.id == template_id)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
return template
@router.put("/{template_id}", response_model=NotificationTemplateResponse)
async def update_template(
template_id: int,
update: NotificationTemplateUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a notification template."""
result = await db.execute(
select(NotificationTemplate).where(NotificationTemplate.id == template_id)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
if update.title_template is not None:
template.title_template = update.title_template
if update.body_template is not None:
template.body_template = update.body_template
await db.commit()
await db.refresh(template)
# Clear template cache so changes take effect immediately
notification_service.clear_template_cache()
return template
@router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
"""Reset a notification template to its default values."""
result = await db.execute(
select(NotificationTemplate).where(NotificationTemplate.id == template_id)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
# Find the default template
default = next(
(t for t in DEFAULT_TEMPLATES if t["event_type"] == template.event_type),
None,
)
if not default:
raise HTTPException(status_code=500, detail="Default template not found")
template.title_template = default["title_template"]
template.body_template = default["body_template"]
await db.commit()
await db.refresh(template)
# Clear template cache so changes take effect immediately
notification_service.clear_template_cache()
return template
@router.post("/preview", response_model=TemplatePreviewResponse)
async def preview_template(request: TemplatePreviewRequest):
"""Preview a template with sample data."""
sample = SAMPLE_DATA.get(request.event_type, {})
# Safe template rendering - replace missing vars with empty string
def safe_format(template: str, data: dict) -> str:
result = template
for key, value in data.items():
result = result.replace("{" + key + "}", str(value))
# Remove any remaining unreplaced placeholders
import re
result = re.sub(r"\{[a-z_]+\}", "", result)
return result
return TemplatePreviewResponse(
title=safe_format(request.title_template, sample),
body=safe_format(request.body_template, sample),
)
+30 -1
View File
@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
async def init_db():
# Import models to register them with SQLAlchemy
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note # noqa: F401
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -42,6 +42,9 @@ async def init_db():
# Run migrations for new columns (SQLite doesn't auto-add columns)
await run_migrations(conn)
# Seed default notification templates
await seed_notification_templates()
async def run_migrations(conn):
"""Add new columns to existing tables if they don't exist."""
@@ -127,3 +130,29 @@ async def run_migrations(conn):
except Exception:
# Column already exists
pass
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
from sqlalchemy import select
from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
async with async_session() as session:
# Check if templates already exist
result = await session.execute(select(NotificationTemplate).limit(1))
if result.scalar_one_or_none() is not None:
# Templates already seeded
return
# Insert default templates
for template_data in DEFAULT_TEMPLATES:
template = NotificationTemplate(
event_type=template_data["event_type"],
name=template_data["name"],
title_template=template_data["title_template"],
body_template=template_data["body_template"],
is_default=True,
)
session.add(template)
await session.commit()
+2 -1
View File
@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
from backend.app.core.database import init_db, async_session
from sqlalchemy import select, or_
from backend.app.core.websocket import ws_manager
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates, maintenance, camera
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera
from backend.app.api.routes import settings as settings_routes
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
@@ -1018,6 +1018,7 @@ app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
app.include_router(notifications.router, prefix=app_settings.api_prefix)
app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
app.include_router(spoolman.router, prefix=app_settings.api_prefix)
app.include_router(updates.router, prefix=app_settings.api_prefix)
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
+2
View File
@@ -5,6 +5,7 @@ from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
from backend.app.models.kprofile_note import KProfileNote
from backend.app.models.notification_template import NotificationTemplate
__all__ = [
"Printer",
@@ -16,4 +17,5 @@ __all__ = [
"PrinterMaintenance",
"MaintenanceHistory",
"KProfileNote",
"NotificationTemplate",
]
@@ -0,0 +1,90 @@
"""Notification template model for customizable notification messages."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class NotificationTemplate(Base):
"""Model for notification message templates."""
__tablename__ = "notification_templates"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
event_type: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
title_template: Mapped[str] = mapped_column(Text, nullable=False)
body_template: Mapped[str] = mapped_column(Text, nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
# Default templates for seeding
DEFAULT_TEMPLATES = [
{
"event_type": "print_start",
"name": "Print Started",
"title_template": "Print Started",
"body_template": "{printer}: {filename}\nEstimated: {estimated_time}",
},
{
"event_type": "print_complete",
"name": "Print Completed",
"title_template": "Print Completed",
"body_template": "{printer}: {filename}\nTime: {duration}\nFilament: {filament_grams}g",
},
{
"event_type": "print_failed",
"name": "Print Failed",
"title_template": "Print Failed",
"body_template": "{printer}: {filename}\nTime: {duration}\nReason: {reason}",
},
{
"event_type": "print_stopped",
"name": "Print Stopped",
"title_template": "Print Stopped",
"body_template": "{printer}: {filename}\nTime: {duration}",
},
{
"event_type": "print_progress",
"name": "Print Progress",
"title_template": "Print {progress}% Complete",
"body_template": "{printer}: {filename}\nRemaining: {remaining_time}",
},
{
"event_type": "printer_offline",
"name": "Printer Offline",
"title_template": "Printer Offline",
"body_template": "{printer} has disconnected",
},
{
"event_type": "printer_error",
"name": "Printer Error",
"title_template": "Printer Error: {error_type}",
"body_template": "{printer}\n{error_detail}",
},
{
"event_type": "filament_low",
"name": "Filament Low",
"title_template": "Filament Low",
"body_template": "{printer}: Slot {slot} at {remaining_percent}%",
},
{
"event_type": "maintenance_due",
"name": "Maintenance Due",
"title_template": "Maintenance Due",
"body_template": "{printer}:\n{items}",
},
{
"event_type": "test",
"name": "Test Notification",
"title_template": "BambuTrack Test",
"body_template": "This is a test notification. If you see this, notifications are working!",
},
]
@@ -0,0 +1,166 @@
"""Pydantic schemas for notification templates."""
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
class EventType(str, Enum):
"""Supported notification event types."""
PRINT_START = "print_start"
PRINT_COMPLETE = "print_complete"
PRINT_FAILED = "print_failed"
PRINT_STOPPED = "print_stopped"
PRINT_PROGRESS = "print_progress"
PRINTER_OFFLINE = "printer_offline"
PRINTER_ERROR = "printer_error"
FILAMENT_LOW = "filament_low"
MAINTENANCE_DUE = "maintenance_due"
TEST = "test"
# Available variables for each event type
EVENT_VARIABLES: dict[str, list[str]] = {
"print_start": ["printer", "filename", "estimated_time", "timestamp", "app_name"],
"print_complete": ["printer", "filename", "duration", "filament_grams", "timestamp", "app_name"],
"print_failed": ["printer", "filename", "duration", "reason", "timestamp", "app_name"],
"print_stopped": ["printer", "filename", "duration", "timestamp", "app_name"],
"print_progress": ["printer", "filename", "progress", "remaining_time", "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"],
"maintenance_due": ["printer", "items", "timestamp", "app_name"],
"test": ["app_name", "timestamp"],
}
# Sample data for previewing templates
SAMPLE_DATA: dict[str, dict[str, str]] = {
"print_start": {
"printer": "Bambu X1C",
"filename": "Benchy.3mf",
"estimated_time": "1h 23m",
"timestamp": "2024-01-15 14:30",
"app_name": "BambuTrack",
},
"print_complete": {
"printer": "Bambu X1C",
"filename": "Benchy.3mf",
"duration": "1h 18m",
"filament_grams": "15.2",
"timestamp": "2024-01-15 15:48",
"app_name": "BambuTrack",
},
"print_failed": {
"printer": "Bambu X1C",
"filename": "Benchy.3mf",
"duration": "0h 45m",
"reason": "Filament runout",
"timestamp": "2024-01-15 15:15",
"app_name": "BambuTrack",
},
"print_stopped": {
"printer": "Bambu X1C",
"filename": "Benchy.3mf",
"duration": "0h 30m",
"timestamp": "2024-01-15 15:00",
"app_name": "BambuTrack",
},
"print_progress": {
"printer": "Bambu X1C",
"filename": "Benchy.3mf",
"progress": "50",
"remaining_time": "0h 41m",
"timestamp": "2024-01-15 15:00",
"app_name": "BambuTrack",
},
"printer_offline": {
"printer": "Bambu X1C",
"timestamp": "2024-01-15 14:30",
"app_name": "BambuTrack",
},
"printer_error": {
"printer": "Bambu X1C",
"error_type": "AMS Error",
"error_detail": "Filament slot 1 jammed",
"timestamp": "2024-01-15 14:30",
"app_name": "BambuTrack",
},
"filament_low": {
"printer": "Bambu X1C",
"slot": "1",
"remaining_percent": "15",
"color": "Black PLA",
"timestamp": "2024-01-15 14:30",
"app_name": "BambuTrack",
},
"maintenance_due": {
"printer": "Bambu X1C",
"items": "• Nozzle cleaning (OVERDUE)\n• Carbon rod lubrication (Soon)",
"timestamp": "2024-01-15 14:30",
"app_name": "BambuTrack",
},
"test": {
"app_name": "BambuTrack",
"timestamp": "2024-01-15 14:30",
},
}
class NotificationTemplateBase(BaseModel):
"""Base schema for notification templates."""
title_template: str = Field(..., min_length=1, max_length=200)
body_template: str = Field(..., min_length=1, max_length=2000)
class NotificationTemplateUpdate(BaseModel):
"""Schema for updating a notification template."""
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)
class NotificationTemplateResponse(NotificationTemplateBase):
"""Schema for notification template API responses."""
id: int
event_type: str
name: str
is_default: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TemplateVariableInfo(BaseModel):
"""Information about a template variable."""
name: str
description: str
class EventVariablesResponse(BaseModel):
"""Response for available variables per event type."""
event_type: str
event_name: str
variables: list[str]
class TemplatePreviewRequest(BaseModel):
"""Request to preview a template with sample data."""
event_type: str
title_template: str
body_template: str
class TemplatePreviewResponse(BaseModel):
"""Response with rendered template preview."""
title: str
body: str
+133 -133
View File
@@ -2,6 +2,7 @@
import json
import logging
import re
import smtplib
from datetime import datetime
from email.mime.multipart import MIMEMultipart
@@ -14,8 +15,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.notification import NotificationProvider
from backend.app.models.settings import Settings
from backend.app.i18n import Translator
from backend.app.models.notification_template import NotificationTemplate
logger = logging.getLogger(__name__)
@@ -25,6 +25,7 @@ class NotificationService:
def __init__(self):
self._http_client: httpx.AsyncClient | None = None
self._template_cache: dict[str, NotificationTemplate] = {}
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
@@ -66,136 +67,77 @@ class NotificationService:
logger.warning(f"Invalid quiet hours format for provider {provider.name}")
return False
async def _get_notification_language(self, db: AsyncSession) -> str:
"""Get the notification language from settings."""
result = await db.execute(
select(Settings).where(Settings.key == "notification_language")
)
setting = result.scalar_one_or_none()
return setting.value if setting else "en"
async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
"""Get a notification template by event type."""
# Check cache first
if event_type in self._template_cache:
return self._template_cache[event_type]
def _format_duration(self, seconds: int | None, translator: Translator) -> str:
result = await db.execute(
select(NotificationTemplate).where(NotificationTemplate.event_type == event_type)
)
template = result.scalar_one_or_none()
if template:
self._template_cache[event_type] = template
return template
def _render_template(self, template_str: str, variables: dict[str, Any]) -> str:
"""Render a template string with variables. Missing variables become empty."""
result = template_str
for key, value in variables.items():
result = result.replace("{" + key + "}", str(value) if value is not None else "")
# Remove any remaining unreplaced placeholders
result = re.sub(r"\{[a-z_]+\}", "", result)
return result
def _format_duration(self, seconds: int | None) -> str:
"""Format duration in seconds to human-readable string."""
if seconds is None:
return translator.t("notification.unknown")
return "Unknown"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if hours > 0:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def _build_print_start_message(self, printer_name: str, data: dict, translator: Translator) -> tuple[str, str]:
"""Build notification message for print start event."""
filename = data.get("filename", translator.t("notification.unknown"))
# Clean up filename
def _clean_filename(self, filename: str) -> str:
"""Remove file extensions from filename."""
if filename.endswith(".gcode.3mf"):
filename = filename[:-10]
return filename[:-10]
elif filename.endswith(".3mf"):
filename = filename[:-4]
return filename[:-4]
return filename
title = translator.t("notification.print_started")
estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
time_str = self._format_duration(estimated_time * 60 if estimated_time else None, translator)
message = f"{printer_name}: {filename}\n{translator.t('notification.estimated')}: {time_str}"
return title, message
def _build_print_complete_message(
self, printer_name: str, status: str, data: dict, translator: Translator, archive_data: dict | None = None
async def _build_message_from_template(
self, db: AsyncSession, event_type: str, variables: dict[str, Any]
) -> tuple[str, str]:
"""Build notification message for print complete event."""
filename = data.get("filename", translator.t("notification.unknown"))
if filename.endswith(".gcode.3mf"):
filename = filename[:-10]
elif filename.endswith(".3mf"):
filename = filename[:-4]
"""Build notification title and body from template."""
# Add common variables
variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
variables["app_name"] = "BambuTrack"
if status == "completed":
title = translator.t("notification.print_completed")
elif status == "failed":
title = translator.t("notification.print_failed")
elif status in ("aborted", "stopped", "cancelled"):
title = translator.t("notification.print_stopped")
else:
title = translator.t("notification.print_ended")
template = await self._get_template(db, event_type)
if not template:
# Fallback to simple message
logger.warning(f"Template not found for event type: {event_type}")
return event_type.replace("_", " ").title(), str(variables)
lines = [f"{printer_name}: {filename}"]
title = self._render_template(template.title_template, variables)
body = self._render_template(template.body_template, variables)
if archive_data:
# Add print time if available
if archive_data.get("print_time_seconds"):
lines.append(f"{translator.t('notification.time')}: {self._format_duration(archive_data['print_time_seconds'], translator)}")
# Add filament used if available
if archive_data.get("actual_filament_grams"):
lines.append(f"{translator.t('notification.filament')}: {archive_data['actual_filament_grams']:.1f}g")
# Add failure reason if failed
if status == "failed" and archive_data.get("failure_reason"):
lines.append(f"{translator.t('notification.reason')}: {archive_data['failure_reason']}")
message = "\n".join(lines)
return title, message
def _build_progress_message(
self, printer_name: str, filename: str, progress: int, translator: Translator
) -> tuple[str, str]:
"""Build notification message for print progress milestone."""
if filename.endswith(".gcode.3mf"):
filename = filename[:-10]
elif filename.endswith(".3mf"):
filename = filename[:-4]
title = translator.t("notification.print_progress", progress=progress)
message = f"{printer_name}: {filename}"
return title, message
def _build_printer_offline_message(self, printer_name: str, translator: Translator) -> tuple[str, str]:
"""Build notification message for printer offline event."""
title = translator.t("notification.printer_offline")
message = translator.t("notification.printer_disconnected", printer=printer_name)
return title, message
def _build_printer_error_message(
self, printer_name: str, error_type: str, translator: Translator, error_detail: str | None = None
) -> tuple[str, str]:
"""Build notification message for printer error event."""
title = translator.t("notification.printer_error", error_type=error_type)
message = f"{printer_name}"
if error_detail:
message += f"\n{error_detail}"
return title, message
def _build_filament_low_message(
self, printer_name: str, slot: int, remaining_percent: int, translator: Translator
) -> tuple[str, str]:
"""Build notification message for low filament event."""
title = translator.t("notification.filament_low")
message = translator.t("notification.slot_at_percent", printer=printer_name, slot=slot, percent=remaining_percent)
return title, message
def _build_maintenance_due_message(
self, printer_name: str, maintenance_items: list[dict], translator: Translator
) -> tuple[str, str]:
"""Build notification message for maintenance due event."""
title = translator.t("notification.maintenance_due")
lines = [f"{printer_name}:"]
for item in maintenance_items:
status = translator.t("notification.overdue") if item.get("is_due") else translator.t("notification.soon")
lines.append(f"• {item['name']} ({status})")
message = "\n".join(lines)
return title, message
return title, body
async def send_test_notification(
self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None
) -> tuple[bool, str]:
"""Send a test notification to verify configuration."""
lang = "en"
if db:
lang = await self._get_notification_language(db)
translator = Translator(lang)
title = translator.t("notification.test_title")
message = translator.t("notification.test_message")
title, message = await self._build_message_from_template(db, "test", {})
else:
title = "BambuTrack Test"
message = "This is a test notification. If you see this, notifications are working!"
try:
if provider_type == "callmebot":
@@ -461,10 +403,18 @@ class NotificationService:
logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
return
lang = await self._get_notification_language(db)
translator = Translator(lang)
filename = self._clean_filename(data.get("filename", "Unknown"))
estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
time_str = self._format_duration(estimated_time * 60 if estimated_time else None)
variables = {
"printer": printer_name,
"filename": filename,
"estimated_time": time_str,
}
logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
title, message = self._build_print_start_message(printer_name, data, translator)
title, message = await self._build_message_from_template(db, "print_start", variables)
await self._send_to_providers(providers, title, message, db)
async def on_print_complete(
@@ -478,27 +428,47 @@ class NotificationService:
):
"""Handle print complete event - send notifications to relevant providers."""
logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}")
# Determine which event type this is
# Determine event type based on status
if status == "completed":
event_field = "on_print_complete"
event_type = "print_complete"
elif status in ("failed",):
event_field = "on_print_failed"
event_type = "print_failed"
elif status in ("aborted", "stopped", "cancelled"):
event_field = "on_print_stopped"
event_type = "print_stopped"
else:
# Unknown status, default to on_print_complete
logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete")
event_field = "on_print_complete"
event_type = "print_complete"
providers = await self._get_providers_for_event(db, event_field, printer_id)
if not providers:
logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
return
lang = await self._get_notification_language(db)
translator = Translator(lang)
filename = self._clean_filename(data.get("filename", "Unknown"))
variables = {
"printer": printer_name,
"filename": filename,
"duration": "",
"filament_grams": "",
"reason": "",
}
if archive_data:
if archive_data.get("print_time_seconds"):
variables["duration"] = self._format_duration(archive_data["print_time_seconds"])
if archive_data.get("actual_filament_grams"):
variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}"
if status == "failed" and archive_data.get("failure_reason"):
variables["reason"] = archive_data["failure_reason"]
logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
title, message = self._build_print_complete_message(printer_name, status, data, translator, archive_data)
title, message = await self._build_message_from_template(db, event_type, variables)
await self._send_to_providers(providers, title, message, db)
async def on_print_progress(
@@ -508,15 +478,21 @@ class NotificationService:
filename: str,
progress: int,
db: AsyncSession,
remaining_time: int | None = None,
):
"""Handle print progress milestone (25%, 50%, 75%)."""
providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
if not providers:
return
lang = await self._get_notification_language(db)
translator = Translator(lang)
title, message = self._build_progress_message(printer_name, filename, progress, translator)
variables = {
"printer": printer_name,
"filename": self._clean_filename(filename),
"progress": str(progress),
"remaining_time": self._format_duration(remaining_time) if remaining_time else "",
}
title, message = await self._build_message_from_template(db, "print_progress", variables)
await self._send_to_providers(providers, title, message, db)
async def on_printer_offline(
@@ -527,9 +503,9 @@ class NotificationService:
if not providers:
return
lang = await self._get_notification_language(db)
translator = Translator(lang)
title, message = self._build_printer_offline_message(printer_name, translator)
variables = {"printer": printer_name}
title, message = await self._build_message_from_template(db, "printer_offline", variables)
await self._send_to_providers(providers, title, message, db)
async def on_printer_error(
@@ -545,9 +521,13 @@ class NotificationService:
if not providers:
return
lang = await self._get_notification_language(db)
translator = Translator(lang)
title, message = self._build_printer_error_message(printer_name, error_type, translator, error_detail)
variables = {
"printer": printer_name,
"error_type": error_type,
"error_detail": error_detail or "",
}
title, message = await self._build_message_from_template(db, "printer_error", variables)
await self._send_to_providers(providers, title, message, db)
async def on_filament_low(
@@ -557,15 +537,21 @@ class NotificationService:
slot: int,
remaining_percent: int,
db: AsyncSession,
color: str | None = None,
):
"""Handle low filament event."""
providers = await self._get_providers_for_event(db, "on_filament_low", printer_id)
if not providers:
return
lang = await self._get_notification_language(db)
translator = Translator(lang)
title, message = self._build_filament_low_message(printer_name, slot, remaining_percent, translator)
variables = {
"printer": printer_name,
"slot": str(slot),
"remaining_percent": str(remaining_percent),
"color": color or "",
}
title, message = await self._build_message_from_template(db, "filament_low", variables)
await self._send_to_providers(providers, title, message, db)
async def on_maintenance_due(
@@ -584,12 +570,26 @@ class NotificationService:
logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
return
lang = await self._get_notification_language(db)
translator = Translator(lang)
# Format maintenance items list
items_list = []
for item in maintenance_items:
status = "OVERDUE" if item.get("is_due") else "Soon"
items_list.append(f"- {item['name']} ({status})")
items_str = "\n".join(items_list)
variables = {
"printer": printer_name,
"items": items_str,
}
logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
title, message = self._build_maintenance_due_message(printer_name, maintenance_items, translator)
title, message = await self._build_message_from_template(db, "maintenance_due", variables)
await self._send_to_providers(providers, title, message, db)
def clear_template_cache(self):
"""Clear the template cache. Call this when templates are updated."""
self._template_cache.clear()
# Global instance
notification_service = NotificationService()
+53
View File
@@ -677,6 +677,40 @@ export interface EmailConfig {
use_tls?: boolean;
}
// Notification Template types
export interface NotificationTemplate {
id: number;
event_type: string;
name: string;
title_template: string;
body_template: string;
is_default: boolean;
created_at: string;
updated_at: string;
}
export interface NotificationTemplateUpdate {
title_template?: string;
body_template?: string;
}
export interface EventVariablesResponse {
event_type: string;
event_name: string;
variables: string[];
}
export interface TemplatePreviewRequest {
event_type: string;
title_template: string;
body_template: string;
}
export interface TemplatePreviewResponse {
title: string;
body: string;
}
// Spoolman types
export interface SpoolmanStatus {
enabled: boolean;
@@ -1230,6 +1264,25 @@ export const api = {
body: JSON.stringify(data),
}),
// Notification Templates
getNotificationTemplates: () => request<NotificationTemplate[]>('/notification-templates'),
getNotificationTemplate: (id: number) => request<NotificationTemplate>(`/notification-templates/${id}`),
updateNotificationTemplate: (id: number, data: NotificationTemplateUpdate) =>
request<NotificationTemplate>(`/notification-templates/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
resetNotificationTemplate: (id: number) =>
request<NotificationTemplate>(`/notification-templates/${id}/reset`, {
method: 'POST',
}),
getTemplateVariables: () => request<EventVariablesResponse[]>('/notification-templates/variables'),
previewTemplate: (data: TemplatePreviewRequest) =>
request<TemplatePreviewResponse>('/notification-templates/preview', {
method: 'POST',
body: JSON.stringify(data),
}),
// Spoolman Integration
getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
connectSpoolman: () =>
@@ -30,6 +30,17 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
// Event toggles
const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);
const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);
const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
// Provider-specific config
const [config, setConfig] = useState<Record<string, string>>(
provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {}
@@ -115,6 +126,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
quiet_hours_enabled: quietHoursEnabled,
quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
// Event toggles
on_print_start: onPrintStart,
on_print_complete: onPrintComplete,
on_print_failed: onPrintFailed,
on_print_stopped: onPrintStopped,
on_print_progress: onPrintProgress,
on_printer_offline: onPrinterOffline,
on_printer_error: onPrinterError,
on_filament_low: onFilamentLow,
on_maintenance_due: onMaintenanceDue,
};
if (isEditing) {
@@ -380,6 +401,64 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
)}
</div>
{/* Event Toggles */}
<div className="space-y-3">
<p className="text-sm text-bambu-gray">Notification Events</p>
{/* Print Events */}
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Print Events</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center justify-between">
<span className="text-sm text-white">Start</span>
<Toggle checked={onPrintStart} onChange={setOnPrintStart} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Complete</span>
<Toggle checked={onPrintComplete} onChange={setOnPrintComplete} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Failed</span>
<Toggle checked={onPrintFailed} onChange={setOnPrintFailed} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Stopped</span>
<Toggle checked={onPrintStopped} onChange={setOnPrintStopped} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">Progress</span>
<span className="text-xs text-bambu-gray ml-1">(25%, 50%, 75%)</span>
</div>
<Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
</div>
</div>
</div>
{/* Printer Status Events */}
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Printer Status</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center justify-between">
<span className="text-sm text-white">Offline</span>
<Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Error</span>
<Toggle checked={onPrinterError} onChange={setOnPrinterError} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Low Filament</span>
<Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Maintenance</span>
<Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} />
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
@@ -90,7 +90,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.last_success && (
<span className="text-xs text-bambu-green">Last sent: {new Date(provider.last_success).toLocaleDateString()}</span>
)}
{provider.last_error && (
{/* Only show error if it's more recent than last success */}
{provider.last_error && provider.last_error_at && (
!provider.last_success || new Date(provider.last_error_at) > new Date(provider.last_success)
) && (
<span className="text-xs text-red-400" title={provider.last_error}>Error</span>
)}
</div>
@@ -130,16 +133,16 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<span className="px-2 py-0.5 bg-gray-500/20 text-gray-400 text-xs rounded">Offline</span>
)}
{provider.on_printer_error && (
<span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">Error</span>
<span className="px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded">Error</span>
)}
{provider.on_filament_low && (
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs rounded">Low Filament</span>
<span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded">Low Filament</span>
)}
{provider.on_maintenance_due && (
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">Maintenance</span>
)}
{provider.quiet_hours_enabled && (
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded flex items-center gap-1">
<span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
<Moon className="w-3 h-3" />
Quiet
</span>
@@ -0,0 +1,276 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Loader2, RotateCcw, Plus, Eye } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationTemplate, NotificationTemplateUpdate } from '../api/client';
import { Button } from './Button';
interface NotificationTemplateEditorProps {
template: NotificationTemplate;
onClose: () => void;
}
export function NotificationTemplateEditor({ template, onClose }: NotificationTemplateEditorProps) {
const queryClient = useQueryClient();
const bodyRef = useRef<HTMLTextAreaElement>(null);
const [titleTemplate, setTitleTemplate] = useState(template.title_template);
const [bodyTemplate, setBodyTemplate] = useState(template.body_template);
const [error, setError] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState(true);
// Fetch variables for this event type
const { data: variablesData } = useQuery({
queryKey: ['template-variables'],
queryFn: api.getTemplateVariables,
});
// Get variables for this template's event type
const eventVariables = variablesData?.find(v => v.event_type === template.event_type);
// Live preview
const { data: preview, isLoading: previewLoading } = useQuery({
queryKey: ['template-preview', template.event_type, titleTemplate, bodyTemplate],
queryFn: () => api.previewTemplate({
event_type: template.event_type,
title_template: titleTemplate,
body_template: bodyTemplate,
}),
enabled: showPreview && titleTemplate.length > 0 && bodyTemplate.length > 0,
});
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: NotificationTemplateUpdate) => api.updateNotificationTemplate(template.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
// Reset mutation
const resetMutation = useMutation({
mutationFn: () => api.resetNotificationTemplate(template.id),
onSuccess: (resetTemplate) => {
setTitleTemplate(resetTemplate.title_template);
setBodyTemplate(resetTemplate.body_template);
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
},
onError: (err: Error) => {
setError(err.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!titleTemplate.trim()) {
setError('Title is required');
return;
}
if (!bodyTemplate.trim()) {
setError('Body is required');
return;
}
updateMutation.mutate({
title_template: titleTemplate,
body_template: bodyTemplate,
});
};
const insertVariable = (variable: string) => {
const textarea = bodyRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = bodyTemplate;
const before = text.substring(0, start);
const after = text.substring(end);
const newValue = before + `{${variable}}` + after;
setBodyTemplate(newValue);
// Restore focus and cursor position
setTimeout(() => {
textarea.focus();
const newCursor = start + variable.length + 2;
textarea.setSelectionRange(newCursor, newCursor);
}, 0);
};
const hasChanges = titleTemplate !== template.title_template || bodyTemplate !== template.body_template;
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
<h2 className="text-lg font-semibold text-white">
Edit Template: {template.name}
</h2>
<button
onClick={onClose}
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
>
<X className="w-5 h-5 text-bambu-gray" />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 space-y-4">
{error && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-sm">
{error}
</div>
)}
{/* Title */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
Title
</label>
<input
type="text"
value={titleTemplate}
onChange={(e) => setTitleTemplate(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green"
placeholder="Notification title..."
/>
</div>
{/* Body */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
Body
</label>
<textarea
ref={bodyRef}
value={bodyTemplate}
onChange={(e) => setBodyTemplate(e.target.value)}
rows={4}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green font-mono text-sm resize-none"
placeholder="Notification body..."
/>
</div>
{/* Available Variables */}
{eventVariables && (
<div>
<label className="block text-sm font-medium text-bambu-gray mb-2">
Available Variables
</label>
<div className="flex flex-wrap gap-2">
{eventVariables.variables.map((variable) => (
<button
key={variable}
type="button"
onClick={() => insertVariable(variable)}
className="inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs text-bambu-gray hover:text-white transition-colors"
>
<Plus className="w-3 h-3" />
{variable}
</button>
))}
</div>
<p className="text-xs text-bambu-gray/60 mt-1">
Click to insert at cursor position in body
</p>
</div>
)}
{/* Preview */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-bambu-gray flex items-center gap-2">
<Eye className="w-4 h-4" />
Live Preview
</label>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="text-xs text-bambu-green hover:text-bambu-green-light"
>
{showPreview ? 'Hide' : 'Show'}
</button>
</div>
{showPreview && (
<div className="bg-bambu-dark border border-bambu-dark-tertiary rounded p-3 space-y-2">
{previewLoading ? (
<div className="flex items-center gap-2 text-bambu-gray text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
Loading preview...
</div>
) : preview ? (
<>
<div>
<span className="text-xs text-bambu-gray">Title:</span>
<div className="text-white font-medium">{preview.title}</div>
</div>
<div>
<span className="text-xs text-bambu-gray">Body:</span>
<div className="text-white whitespace-pre-wrap text-sm">{preview.body}</div>
</div>
</>
) : (
<div className="text-bambu-gray text-sm">
Enter template content to see preview
</div>
)}
</div>
)}
</div>
</form>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary shrink-0">
<Button
type="button"
variant="ghost"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
className="text-orange-400 hover:text-orange-300"
>
{resetMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<RotateCcw className="w-4 h-4 mr-2" />
)}
Reset to Default
</Button>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={updateMutation.isPending || !hasChanges}
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
Save
</Button>
</div>
</div>
</div>
</div>
);
}
+146 -75
View File
@@ -1,14 +1,15 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client';
import type { AppSettings, SmartPlug, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
import { NotificationProviderCard } from '../components/NotificationProviderCard';
import { AddNotificationModal } from '../components/AddNotificationModal';
import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
import { SpoolmanSettings } from '../components/SpoolmanSettings';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
import { availableLanguages } from '../i18n';
@@ -24,6 +25,7 @@ export function SettingsPage() {
const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
@@ -52,6 +54,11 @@ export function SettingsPage() {
queryFn: api.getNotificationProviders,
});
const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({
queryKey: ['notification-templates'],
queryFn: api.getNotificationTemplates,
});
const { data: ffmpegStatus } = useQuery({
queryKey: ['ffmpeg-status'],
queryFn: api.checkFfmpeg,
@@ -741,88 +748,144 @@ export function SettingsPage() {
{/* Notifications Tab */}
{activeTab === 'notifications' && (
<div className="max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column: Providers */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Bell className="w-5 h-5 text-bambu-green" />
Notifications
Providers
</h2>
<p className="text-sm text-bambu-gray mt-1">
Get notified about print events via WhatsApp, Telegram, Email, Discord, and more.
</p>
<Button
size="sm"
onClick={() => {
setEditingProvider(null);
setShowNotificationModal(true);
}}
>
<Plus className="w-4 h-4" />
Add
</Button>
</div>
<Button
onClick={() => {
setEditingProvider(null);
setShowNotificationModal(true);
}}
>
<Plus className="w-4 h-4" />
Add Provider
</Button>
</div>
{/* Notification Language Setting */}
<Card className="mb-6">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white font-medium">{t('settings.notificationLanguage')}</p>
<p className="text-sm text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
</div>
<select
value={localSettings.notification_language || 'en'}
onChange={(e) => updateSetting('notification_language', e.target.value)}
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green"
>
{availableLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName}
</option>
))}
</select>
</div>
</CardContent>
</Card>
{providersLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : notificationProviders && notificationProviders.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{notificationProviders.map((provider) => (
<NotificationProviderCard
key={provider.id}
provider={provider}
onEdit={(p) => {
setEditingProvider(p);
setShowNotificationModal(true);
}}
/>
))}
</div>
) : (
<Card>
<CardContent className="py-12">
<div className="text-center text-bambu-gray">
<Bell className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium text-white mb-2">No notification providers configured</p>
<p className="text-sm mb-4">Add a notification provider to receive alerts about your print jobs.</p>
<Button
onClick={() => {
setEditingProvider(null);
setShowNotificationModal(true);
}}
{/* Notification Language Setting */}
<Card className="mb-4">
<CardContent className="py-3">
<div className="flex items-center justify-between">
<div>
<p className="text-white text-sm font-medium">{t('settings.notificationLanguage')}</p>
<p className="text-xs text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
</div>
<select
value={localSettings.notification_language || 'en'}
onChange={(e) => updateSetting('notification_language', e.target.value)}
className="px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green"
>
<Plus className="w-4 h-4" />
Add Your First Provider
</Button>
{availableLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName}
</option>
))}
</select>
</div>
</CardContent>
</Card>
)}
{providersLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : notificationProviders && notificationProviders.length > 0 ? (
<div className="space-y-3">
{notificationProviders.map((provider) => (
<NotificationProviderCard
key={provider.id}
provider={provider}
onEdit={(p) => {
setEditingProvider(p);
setShowNotificationModal(true);
}}
/>
))}
</div>
) : (
<Card>
<CardContent className="py-8">
<div className="text-center text-bambu-gray">
<Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm font-medium text-white mb-2">No providers configured</p>
<p className="text-xs mb-3">Add a provider to receive alerts.</p>
<Button
size="sm"
onClick={() => {
setEditingProvider(null);
setShowNotificationModal(true);
}}
>
<Plus className="w-4 h-4" />
Add Provider
</Button>
</div>
</CardContent>
</Card>
)}
</div>
{/* Right Column: Templates */}
<div>
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-bambu-green" />
Message Templates
</h2>
<p className="text-sm text-bambu-gray mb-4">
Customize notification messages for each event.
</p>
{templatesLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : notificationTemplates && notificationTemplates.length > 0 ? (
<div className="space-y-2">
{notificationTemplates.map((template) => (
<Card
key={template.id}
className="cursor-pointer hover:border-bambu-green/50 transition-colors"
onClick={() => setEditingTemplate(template)}
>
<CardContent className="py-2.5 px-3">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-white font-medium text-sm truncate">{template.name}</p>
<p className="text-bambu-gray text-xs truncate mt-0.5">
{template.title_template}
</p>
</div>
<button
className="p-1.5 hover:bg-bambu-dark-tertiary rounded transition-colors shrink-0 ml-2"
onClick={(e) => {
e.stopPropagation();
setEditingTemplate(template);
}}
>
<Edit2 className="w-4 h-4 text-bambu-gray" />
</button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="py-8">
<div className="text-center text-bambu-gray">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No templates available. Restart the backend to seed default templates.</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
)}
@@ -847,6 +910,14 @@ export function SettingsPage() {
}}
/>
)}
{/* Template Editor Modal */}
{editingTemplate && (
<NotificationTemplateEditor
template={editingTemplate}
onClose={() => setEditingTemplate(null)}
/>
)}
</div>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-6YKRUSex.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BP1T2ML7.css">
<script type="module" crossorigin src="/assets/index-C99F9s0L.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BomzirkH.css">
</head>
<body>
<div id="root"></div>