mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Added template system to notification module.
This commit is contained in:
@@ -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),
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user