mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Feature: Advanced Authentication User Email Notifications (#693)
Feature: Advanced Authentication User Email Notifications (#693)
This commit is contained in:
@@ -46,6 +46,11 @@ EVENT_NAMES = {
|
||||
# User management
|
||||
"user_created": "Welcome Email",
|
||||
"password_reset": "Password Reset",
|
||||
# User email print notifications
|
||||
"user_print_start": "User Print Started Email",
|
||||
"user_print_complete": "User Print Completed Email",
|
||||
"user_print_failed": "User Print Failed Email",
|
||||
"user_print_stopped": "User Print Stopped Email",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -783,6 +783,17 @@ async def stop_queue_item(
|
||||
except Exception as e:
|
||||
logger.error("Error sending stop command for queue item %s: %s", item_id, e)
|
||||
|
||||
# Mark this printer as user-stopped BEFORE the first await so that if the
|
||||
# MQTT on_print_complete callback fires during the db.commit() yield the flag
|
||||
# is already set and the "failed" status will be correctly overridden to
|
||||
# "cancelled" (preventing a spurious "print failed" notification).
|
||||
try:
|
||||
from backend.app.main import mark_printer_stopped_by_user
|
||||
|
||||
mark_printer_stopped_by_user(printer_id)
|
||||
except Exception as _mark_err:
|
||||
logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err)
|
||||
|
||||
# Update queue item status regardless - if printer is off, print is already stopped
|
||||
item.status = "cancelled"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -98,6 +98,7 @@ async def get_settings(
|
||||
"ha_enabled",
|
||||
"per_printer_mapping_expanded",
|
||||
"prometheus_enabled",
|
||||
"user_notifications_enabled",
|
||||
"queue_drying_enabled",
|
||||
"queue_drying_block",
|
||||
"ambient_drying_enabled",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""API routes for user email notification preferences."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
from backend.app.models.user_email_pref import UserEmailPreference
|
||||
from backend.app.schemas.user_notifications import UserEmailPreferenceResponse, UserEmailPreferenceUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/user-notifications", tags=["user-notifications"])
|
||||
|
||||
|
||||
@router.get("/preferences", response_model=UserEmailPreferenceResponse)
|
||||
async def get_user_email_preferences(
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get the current user's email notification preferences.
|
||||
|
||||
Returns defaults (all enabled) if no preferences are saved yet.
|
||||
"""
|
||||
if current_user is None:
|
||||
# Auth is disabled; no user context available, return defaults
|
||||
return UserEmailPreferenceResponse(
|
||||
notify_print_start=True,
|
||||
notify_print_complete=True,
|
||||
notify_print_failed=True,
|
||||
notify_print_stopped=True,
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id)
|
||||
)
|
||||
pref = result.scalar_one_or_none()
|
||||
|
||||
if pref is None:
|
||||
# Return defaults
|
||||
return UserEmailPreferenceResponse(
|
||||
notify_print_start=True,
|
||||
notify_print_complete=True,
|
||||
notify_print_failed=True,
|
||||
notify_print_stopped=True,
|
||||
)
|
||||
|
||||
return pref
|
||||
|
||||
|
||||
@router.put("/preferences", response_model=UserEmailPreferenceResponse)
|
||||
async def update_user_email_preferences(
|
||||
data: UserEmailPreferenceUpdate,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update the current user's email notification preferences."""
|
||||
if current_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Authentication must be enabled to save user notification preferences",
|
||||
)
|
||||
|
||||
if not current_user.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User must have an email address to receive notifications",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id)
|
||||
)
|
||||
pref = result.scalar_one_or_none()
|
||||
|
||||
if pref is None:
|
||||
pref = UserEmailPreference(
|
||||
user_id=current_user.id,
|
||||
notify_print_start=data.notify_print_start,
|
||||
notify_print_complete=data.notify_print_complete,
|
||||
notify_print_failed=data.notify_print_failed,
|
||||
notify_print_stopped=data.notify_print_stopped,
|
||||
)
|
||||
db.add(pref)
|
||||
else:
|
||||
pref.notify_print_start = data.notify_print_start
|
||||
pref.notify_print_complete = data.notify_print_complete
|
||||
pref.notify_print_failed = data.notify_print_failed
|
||||
pref.notify_print_stopped = data.notify_print_stopped
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(pref)
|
||||
|
||||
logger.info(
|
||||
"Updated email notification preferences for user %s: start=%s, complete=%s, failed=%s, stopped=%s",
|
||||
current_user.username,
|
||||
pref.notify_print_start,
|
||||
pref.notify_print_complete,
|
||||
pref.notify_print_failed,
|
||||
pref.notify_print_stopped,
|
||||
)
|
||||
|
||||
return pref
|
||||
@@ -278,7 +278,8 @@ async def update_user(
|
||||
user.groups = list(groups)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
|
||||
user = result.scalar_one()
|
||||
|
||||
return _user_to_response(user)
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ async def init_db():
|
||||
spool_usage_history,
|
||||
spoolbuddy_device,
|
||||
user,
|
||||
user_email_pref,
|
||||
virtual_printer,
|
||||
)
|
||||
|
||||
@@ -1455,6 +1456,52 @@ async def run_migrations(conn):
|
||||
for key in obsolete_keys:
|
||||
await conn.execute(text("DELETE FROM settings WHERE key = :key"), {"key": key})
|
||||
|
||||
# Migration: Create user_email_preferences table for user-specific email notification settings
|
||||
try:
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE IF NOT EXISTS user_email_preferences (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
notify_print_start BOOLEAN NOT NULL DEFAULT 1,
|
||||
notify_print_complete BOOLEAN NOT NULL DEFAULT 1,
|
||||
notify_print_failed BOOLEAN NOT NULL DEFAULT 1,
|
||||
notify_print_stopped BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS ix_user_email_preferences_user_id ON user_email_preferences(user_id)")
|
||||
)
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
# Legacy migration: Add notify_print_stopped column (for any existing partial tables)
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1"
|
||||
)
|
||||
)
|
||||
except OperationalError:
|
||||
pass # Column already exists or table created with full schema
|
||||
|
||||
# Seed default settings keys that must exist on fresh install
|
||||
default_settings = [
|
||||
("advanced_auth_enabled", "false"),
|
||||
("smtp_auth_enabled", "true"),
|
||||
]
|
||||
for key, value in default_settings:
|
||||
try:
|
||||
await conn.execute(
|
||||
text("INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)"),
|
||||
{"key": key, "value": value},
|
||||
)
|
||||
except OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
@@ -97,7 +97,7 @@ class Permission(StrEnum):
|
||||
NOTIFICATIONS_CREATE = "notifications:create"
|
||||
NOTIFICATIONS_UPDATE = "notifications:update"
|
||||
NOTIFICATIONS_DELETE = "notifications:delete"
|
||||
|
||||
NOTIFICATIONS_USER_EMAIL = "notifications:user_email" # Receive per-user print email notifications
|
||||
# Notification Templates
|
||||
NOTIFICATION_TEMPLATES_READ = "notification_templates:read"
|
||||
NOTIFICATION_TEMPLATES_UPDATE = "notification_templates:update"
|
||||
@@ -244,6 +244,7 @@ PERMISSION_CATEGORIES = {
|
||||
Permission.NOTIFICATIONS_CREATE,
|
||||
Permission.NOTIFICATIONS_UPDATE,
|
||||
Permission.NOTIFICATIONS_DELETE,
|
||||
Permission.NOTIFICATIONS_USER_EMAIL,
|
||||
Permission.NOTIFICATION_TEMPLATES_READ,
|
||||
Permission.NOTIFICATION_TEMPLATES_UPDATE,
|
||||
],
|
||||
@@ -381,6 +382,7 @@ DEFAULT_GROUPS = {
|
||||
Permission.NOTIFICATIONS_CREATE.value,
|
||||
Permission.NOTIFICATIONS_UPDATE.value,
|
||||
Permission.NOTIFICATIONS_DELETE.value,
|
||||
Permission.NOTIFICATIONS_USER_EMAIL.value,
|
||||
Permission.NOTIFICATION_TEMPLATES_READ.value,
|
||||
Permission.NOTIFICATION_TEMPLATES_UPDATE.value,
|
||||
# External Links - full access
|
||||
|
||||
+327
-5
@@ -45,6 +45,7 @@ from backend.app.api.routes import (
|
||||
support,
|
||||
system,
|
||||
updates,
|
||||
user_notifications,
|
||||
users,
|
||||
virtual_printers,
|
||||
webhook,
|
||||
@@ -280,6 +281,28 @@ _timelapse_baselines: dict[int, set[str]] = {}
|
||||
# Track active bed cooldown monitoring tasks: {printer_id: asyncio.Task}
|
||||
_bed_cooldown_tasks: dict[int, asyncio.Task] = {}
|
||||
|
||||
# Track printers where the user explicitly stopped the print from the queue UI.
|
||||
# When on_print_complete fires with status "failed" for these printers we treat it
|
||||
# as "cancelled" (stopped by user) so the correct notification email is sent.
|
||||
_user_stopped_printers: set[int] = set()
|
||||
|
||||
# Track created_by_id for expected prints so the user email can be sent even when
|
||||
# the archive itself doesn't have created_by_id set (e.g. library-file-based prints).
|
||||
# {(printer_id, filename): created_by_id}
|
||||
_expected_print_creators: dict[tuple[int, str], int] = {}
|
||||
|
||||
# TTL for expected-print entries: evict registrations older than this to prevent
|
||||
# unbounded growth when a print is registered but never starts (e.g. printer
|
||||
# disconnect, app restart, print started from the printer panel).
|
||||
_EXPECTED_PRINT_TTL_SECONDS: int = 2 * 60 * 60 # 2 hours
|
||||
|
||||
# Registration timestamps used for TTL eviction: {(printer_id, filename): monotonic_time}
|
||||
_expected_print_registered_at: dict[tuple[int, str], float] = {}
|
||||
|
||||
# Cleanup loop interval
|
||||
_EXPECTED_PRINT_CLEANUP_INTERVAL: int = 15 * 60 # 15 minutes
|
||||
_expected_prints_cleanup_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def _get_plug_energy(plug, db) -> dict | None:
|
||||
"""Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
|
||||
@@ -308,7 +331,13 @@ async def _get_plug_energy(plug, db) -> dict | None:
|
||||
return await tasmota_service.get_energy(plug)
|
||||
|
||||
|
||||
def register_expected_print(printer_id: int, filename: str, archive_id: int, ams_mapping: list[int] | None = None):
|
||||
def register_expected_print(
|
||||
printer_id: int,
|
||||
filename: str,
|
||||
archive_id: int,
|
||||
ams_mapping: list[int] | None = None,
|
||||
created_by_id: int | None = None,
|
||||
):
|
||||
"""Register an expected print from reprint/scheduled so we don't create duplicate archives."""
|
||||
# Store with multiple filename variations to catch different naming patterns
|
||||
_expected_prints[(printer_id, filename)] = archive_id
|
||||
@@ -320,6 +349,21 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int, ams
|
||||
# Store AMS mapping for usage tracking at print completion
|
||||
if ams_mapping is not None:
|
||||
_print_ams_mappings[archive_id] = ams_mapping
|
||||
# Store created_by_id so the user start email can be sent even when the archive
|
||||
# itself has no created_by_id (e.g. library-file-based queue prints)
|
||||
if created_by_id is not None:
|
||||
_expected_print_creators[(printer_id, filename)] = created_by_id
|
||||
if filename.endswith(".3mf"):
|
||||
base = filename[:-4]
|
||||
_expected_print_creators[(printer_id, base)] = created_by_id
|
||||
_expected_print_creators[(printer_id, f"{base}.gcode")] = created_by_id
|
||||
# Record registration time for TTL-based eviction
|
||||
_registered_at = time.monotonic()
|
||||
_expected_print_registered_at[(printer_id, filename)] = _registered_at
|
||||
if filename.endswith(".3mf"):
|
||||
base = filename[:-4]
|
||||
_expected_print_registered_at[(printer_id, base)] = _registered_at
|
||||
_expected_print_registered_at[(printer_id, f"{base}.gcode")] = _registered_at
|
||||
logging.getLogger(__name__).info(
|
||||
f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}"
|
||||
)
|
||||
@@ -332,6 +376,15 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
|
||||
stored_ams_mapping = _print_ams_mappings.get(archive_id)
|
||||
return stored_ams_mapping
|
||||
|
||||
def mark_printer_stopped_by_user(printer_id: int) -> None:
|
||||
"""Mark that the active print on this printer was stopped by the user from the queue UI.
|
||||
|
||||
When on_print_complete fires with status 'failed' for a printer in this set we
|
||||
reclassify it as 'cancelled' so the correct 'print stopped' notification is sent
|
||||
rather than a 'print failed' notification.
|
||||
"""
|
||||
_user_stopped_printers.add(printer_id)
|
||||
logging.getLogger(__name__).info("Marked printer %s as user-stopped from queue", printer_id)
|
||||
|
||||
_last_status_broadcast: dict[int, str] = {}
|
||||
# Track printers where we've updated nozzle_count
|
||||
@@ -1035,10 +1088,55 @@ async def _send_print_start_notification(
|
||||
archive_data["image_data"] = image_data
|
||||
|
||||
await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data)
|
||||
|
||||
# Send user-specific email notification for print start
|
||||
if archive_data and archive_data.get("created_by_id"):
|
||||
await notification_service.send_user_print_email(
|
||||
event_type="user_print_start",
|
||||
created_by_id=archive_data["created_by_id"],
|
||||
printer_name=printer_name,
|
||||
filename=data.get("subtask_name") or data.get("filename", "Unknown"),
|
||||
db=db,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Notification on_print_start failed: %s", e)
|
||||
|
||||
|
||||
async def _dispatch_user_print_email(
|
||||
status: str,
|
||||
created_by_id: int | None,
|
||||
printer_name: str,
|
||||
filename: str,
|
||||
db,
|
||||
) -> None:
|
||||
"""Send a user-specific print-completion email based on print status.
|
||||
|
||||
Maps the normalised print status to the correct event type and delegates
|
||||
to :meth:`NotificationService.send_user_print_email`. A single helper
|
||||
avoids duplicating the ``if status == "completed" / elif "failed" / elif
|
||||
"stopped"`` dispatch block at every call site.
|
||||
|
||||
Does nothing if *created_by_id* is ``None``.
|
||||
"""
|
||||
if created_by_id is None:
|
||||
return
|
||||
if status == "completed":
|
||||
event_type = "user_print_complete"
|
||||
elif status == "failed":
|
||||
event_type = "user_print_failed"
|
||||
elif status in ("stopped", "aborted", "cancelled"):
|
||||
event_type = "user_print_stopped"
|
||||
else:
|
||||
return
|
||||
await notification_service.send_user_print_email(
|
||||
event_type=event_type,
|
||||
created_by_id=created_by_id,
|
||||
printer_name=printer_name,
|
||||
filename=filename,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
|
||||
"""Extract printable objects from an archive's 3MF file and store in printer state."""
|
||||
try:
|
||||
@@ -1067,6 +1165,9 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
|
||||
logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys()))
|
||||
|
||||
# Clear any stale user-stopped flag from previous print cycles
|
||||
_user_stopped_printers.discard(printer_id)
|
||||
|
||||
# Cancel any active bed cooldown task for this printer
|
||||
existing_task = _bed_cooldown_tasks.pop(printer_id, None)
|
||||
if existing_task and not existing_task.done():
|
||||
@@ -1222,7 +1323,36 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
f"[CALLBACK] Skipping archive - printer: {printer is not None}, auto_archive: {printer.auto_archive if printer else 'N/A'}"
|
||||
)
|
||||
if not notification_sent:
|
||||
await _send_print_start_notification(printer_id, data, logger=logger)
|
||||
# Even with auto-archive disabled, try to recover created_by_id from
|
||||
# a registered expected print (e.g. a library-file queue item) so the
|
||||
# user start email can still be sent.
|
||||
_fn = data.get("filename", "")
|
||||
_sn = data.get("subtask_name", "")
|
||||
_no_archive_creator_keys: list[tuple[int, str]] = []
|
||||
if _sn:
|
||||
_no_archive_creator_keys += [
|
||||
(printer_id, _sn),
|
||||
(printer_id, f"{_sn}.3mf"),
|
||||
(printer_id, f"{_sn}.gcode.3mf"),
|
||||
]
|
||||
if _fn:
|
||||
_base_fn = _fn.split("/")[-1] if "/" in _fn else _fn
|
||||
_no_archive_creator_keys.append((printer_id, _base_fn))
|
||||
_no_archive_base = _base_fn.replace(".gcode", "").replace(".3mf", "")
|
||||
_no_archive_creator_keys += [
|
||||
(printer_id, _no_archive_base),
|
||||
(printer_id, f"{_no_archive_base}.3mf"),
|
||||
]
|
||||
_no_archive_creator: int | None = None
|
||||
for _key in _no_archive_creator_keys:
|
||||
# Clean up all dicts for every key to avoid memory leaks
|
||||
_expected_prints.pop(_key, None)
|
||||
_expected_print_registered_at.pop(_key, None)
|
||||
popped_creator = _expected_print_creators.pop(_key, None)
|
||||
if _no_archive_creator is None:
|
||||
_no_archive_creator = popped_creator
|
||||
_creator_data = {"created_by_id": _no_archive_creator} if _no_archive_creator else None
|
||||
await _send_print_start_notification(printer_id, data, _creator_data, logger)
|
||||
return
|
||||
|
||||
# Get the filename and subtask_name
|
||||
@@ -1264,10 +1394,12 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
expected_archive_id = None
|
||||
for key in expected_keys:
|
||||
expected_archive_id = _expected_prints.pop(key, None)
|
||||
_expected_print_registered_at.pop(key, None)
|
||||
if expected_archive_id:
|
||||
# Clean up other possible keys for this print
|
||||
for other_key in expected_keys:
|
||||
_expected_prints.pop(other_key, None)
|
||||
_expected_print_registered_at.pop(other_key, None)
|
||||
break
|
||||
|
||||
if expected_archive_id:
|
||||
@@ -1320,7 +1452,19 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
|
||||
# Send notification with archive data (reprint/scheduled)
|
||||
if not notification_sent:
|
||||
archive_data = {"print_time_seconds": archive.print_time_seconds}
|
||||
# Use archive's created_by_id; fall back to the creator registered via
|
||||
# register_expected_print (handles library-file-based queue items where
|
||||
# the freshly-created archive has no created_by_id yet).
|
||||
# Pop ALL matching keys so no stale entries remain in the dict.
|
||||
fallback_creator = None
|
||||
for key in expected_keys:
|
||||
popped = _expected_print_creators.pop(key, None)
|
||||
if fallback_creator is None:
|
||||
fallback_creator = popped
|
||||
archive_data = {
|
||||
"print_time_seconds": archive.print_time_seconds,
|
||||
"created_by_id": archive.created_by_id or fallback_creator,
|
||||
}
|
||||
await _send_print_start_notification(printer_id, data, archive_data, logger)
|
||||
|
||||
# Extract printable objects from the archived 3MF file
|
||||
@@ -1401,7 +1545,10 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
logger.warning("Failed to record starting energy for existing archive: %s", e)
|
||||
# Send notification with archive data (existing archive)
|
||||
if not notification_sent:
|
||||
archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
|
||||
archive_data = {
|
||||
"print_time_seconds": existing_archive.print_time_seconds,
|
||||
"created_by_id": existing_archive.created_by_id,
|
||||
}
|
||||
await _send_print_start_notification(printer_id, data, archive_data, logger)
|
||||
# Extract printable objects from the archived 3MF file
|
||||
_load_objects_from_archive(existing_archive, printer_id, logger)
|
||||
@@ -1749,7 +1896,10 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
|
||||
# Send notification with archive data (new archive created)
|
||||
if not notification_sent:
|
||||
archive_data = {"print_time_seconds": archive.print_time_seconds}
|
||||
archive_data = {
|
||||
"print_time_seconds": archive.print_time_seconds,
|
||||
"created_by_id": archive.created_by_id,
|
||||
}
|
||||
await _send_print_start_notification(printer_id, data, archive_data, logger)
|
||||
|
||||
# Extract printable objects for skip object functionality
|
||||
@@ -2060,6 +2210,20 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
# Clear current print user tracking (Issue #206)
|
||||
printer_manager.clear_current_print_user(printer_id)
|
||||
|
||||
# If the user explicitly stopped this print from the queue UI the printer will
|
||||
# report "failed" or "aborted" via MQTT. Override that to "cancelled" so the
|
||||
# correct "print stopped" notification/email is sent instead of a failure alert.
|
||||
_raw_status = data.get("status", "completed")
|
||||
if printer_id in _user_stopped_printers and _raw_status in ("failed", "aborted"):
|
||||
logger.info(
|
||||
"[CALLBACK] Overriding status '%s' -> 'cancelled' for printer %s "
|
||||
"(print was stopped from queue by user)",
|
||||
_raw_status,
|
||||
printer_id,
|
||||
)
|
||||
data = {**data, "status": "cancelled"}
|
||||
_user_stopped_printers.discard(printer_id)
|
||||
|
||||
# MQTT relay - publish print complete
|
||||
try:
|
||||
printer_info = printer_manager.get_printer(printer_id)
|
||||
@@ -2421,6 +2585,76 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
|
||||
if not archive_id:
|
||||
logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name)
|
||||
|
||||
# Still send print-complete/failed/stopped notifications even without an archive.
|
||||
# Try to enrich with queue/library-file data so user-specific emails work too.
|
||||
async def _notify_no_archive():
|
||||
try:
|
||||
async with async_session() as db:
|
||||
from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer_obj = result.scalar_one_or_none()
|
||||
p_name = printer_obj.name if printer_obj else f"Printer {printer_id}"
|
||||
|
||||
# Try to find the most-recent queue item for this printer so we can
|
||||
# recover created_by_id and estimated print time.
|
||||
# NOTE: By the time this task runs the queue item status has already
|
||||
# been updated to a terminal state (completed/failed/cancelled), so
|
||||
# we look for recently-completed items (within the last 5 minutes).
|
||||
no_archive_data: dict | None = None
|
||||
try:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
q_result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.printer_id == printer_id)
|
||||
.where(PrintQueueItem.status.in_(["completed", "failed", "cancelled"]))
|
||||
.where(PrintQueueItem.completed_at >= cutoff)
|
||||
.order_by(PrintQueueItem.completed_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
queue_item = q_result.scalar_one_or_none()
|
||||
if queue_item:
|
||||
no_archive_data = {"created_by_id": queue_item.created_by_id}
|
||||
# Pull estimated time from library file when available
|
||||
if queue_item.library_file_id:
|
||||
lib_result = await db.execute(
|
||||
select(LibraryFile).where(LibraryFile.id == queue_item.library_file_id)
|
||||
)
|
||||
lib_file = lib_result.scalar_one_or_none()
|
||||
if lib_file and lib_file.print_time_seconds:
|
||||
no_archive_data["print_time_seconds"] = lib_file.print_time_seconds
|
||||
except Exception as lookup_err:
|
||||
logger.debug(
|
||||
"[NOTIFY-BG] Could not look up queue item for no-archive notification: %s", lookup_err
|
||||
)
|
||||
|
||||
ps = data.get("status", "completed")
|
||||
logger.info(
|
||||
"[NOTIFY-BG] Sending notification without archive: printer=%s, status=%s", printer_id, ps
|
||||
)
|
||||
await notification_service.on_print_complete(
|
||||
printer_id, p_name, ps, data, db, archive_data=no_archive_data
|
||||
)
|
||||
|
||||
# Send user-specific email if we have a created_by_id
|
||||
if no_archive_data and no_archive_data.get("created_by_id"):
|
||||
raw_filename = data.get("subtask_name") or data.get("filename", "Unknown")
|
||||
await _dispatch_user_print_email(
|
||||
ps,
|
||||
no_archive_data["created_by_id"],
|
||||
p_name,
|
||||
raw_filename,
|
||||
db,
|
||||
)
|
||||
logger.info("[NOTIFY-BG] Completed (no-archive path)")
|
||||
except Exception as e:
|
||||
logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True)
|
||||
|
||||
task = asyncio.create_task(_notify_no_archive())
|
||||
task.add_done_callback(lambda t: None)
|
||||
return
|
||||
|
||||
log_timing("Archive lookup")
|
||||
@@ -2760,6 +2994,7 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
"print_time_seconds": archive.print_time_seconds,
|
||||
"actual_filament_grams": archive.filament_used_grams,
|
||||
"failure_reason": archive.failure_reason,
|
||||
"created_by_id": archive.created_by_id,
|
||||
}
|
||||
|
||||
# Scale filament usage for partial prints
|
||||
@@ -2822,6 +3057,19 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
await notification_service.on_print_complete(
|
||||
printer_id, printer_name, print_status, data, db, archive_data=archive_data
|
||||
)
|
||||
|
||||
# Send user-specific email notification
|
||||
if archive_data:
|
||||
created_by_id = archive_data.get("created_by_id")
|
||||
raw_filename = data.get("subtask_name") or data.get("filename", "Unknown")
|
||||
await _dispatch_user_print_email(
|
||||
print_status,
|
||||
created_by_id,
|
||||
printer_name,
|
||||
raw_filename,
|
||||
db,
|
||||
)
|
||||
|
||||
logger.info("[NOTIFY-BG] Completed")
|
||||
except Exception as e:
|
||||
logger.warning("[NOTIFY-BG] Failed: %s", e)
|
||||
@@ -3315,6 +3563,74 @@ def stop_camera_cleanup():
|
||||
logging.getLogger(__name__).info("Camera stream cleanup stopped")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expected-print TTL eviction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _evict_stale_expected_prints() -> None:
|
||||
"""Remove entries from _expected_prints / _expected_print_creators that are
|
||||
older than _EXPECTED_PRINT_TTL_SECONDS.
|
||||
|
||||
This prevents unbounded growth when a print is registered (via
|
||||
register_expected_print) but on_print_start never fires — e.g. because the
|
||||
printer disconnects, the app restarts, or the print is started directly from
|
||||
the printer panel without going through the queue.
|
||||
"""
|
||||
# Use monotonic time so the TTL is unaffected by system clock adjustments
|
||||
# (e.g. NTP sync, DST changes).
|
||||
cutoff = time.monotonic() - _EXPECTED_PRINT_TTL_SECONDS
|
||||
stale_keys = [k for k, t in _expected_print_registered_at.items() if t < cutoff]
|
||||
if not stale_keys:
|
||||
return
|
||||
|
||||
evicted_archive_ids: set[int] = set()
|
||||
for key in stale_keys:
|
||||
archive_id = _expected_prints.pop(key, None)
|
||||
if archive_id is not None:
|
||||
evicted_archive_ids.add(archive_id)
|
||||
_expected_print_creators.pop(key, None)
|
||||
_expected_print_registered_at.pop(key, None)
|
||||
|
||||
# Also clean up _print_ams_mappings for archive_ids that have no remaining
|
||||
# live keys in _expected_prints (i.e. all variants were just evicted).
|
||||
live_archive_ids = set(_expected_prints.values())
|
||||
for archive_id in evicted_archive_ids:
|
||||
if archive_id not in live_archive_ids:
|
||||
_print_ams_mappings.pop(archive_id, None)
|
||||
|
||||
logging.getLogger(__name__).info(
|
||||
"Evicted %d stale expected-print entries (TTL=%ds)", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS
|
||||
)
|
||||
|
||||
|
||||
async def _expected_prints_cleanup_loop() -> None:
|
||||
"""Background task: periodically evict stale expected-print entries."""
|
||||
while True:
|
||||
try:
|
||||
_evict_stale_expected_prints()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning("Expected prints cleanup failed: %s", e)
|
||||
await asyncio.sleep(_EXPECTED_PRINT_CLEANUP_INTERVAL)
|
||||
|
||||
|
||||
def start_expected_prints_cleanup() -> None:
|
||||
global _expected_prints_cleanup_task
|
||||
if _expected_prints_cleanup_task is None:
|
||||
_expected_prints_cleanup_task = asyncio.create_task(_expected_prints_cleanup_loop())
|
||||
logging.getLogger(__name__).info("Expected prints cleanup started")
|
||||
|
||||
|
||||
def stop_expected_prints_cleanup() -> None:
|
||||
global _expected_prints_cleanup_task
|
||||
if _expected_prints_cleanup_task:
|
||||
_expected_prints_cleanup_task.cancel()
|
||||
_expected_prints_cleanup_task = None
|
||||
logging.getLogger(__name__).info("Expected prints cleanup stopped")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
@@ -3469,6 +3785,10 @@ async def lifespan(app: FastAPI):
|
||||
# Start camera stream orphan cleanup
|
||||
start_camera_cleanup()
|
||||
|
||||
# Start expected-print TTL eviction (prevents memory leak when prints are
|
||||
# registered but on_print_start never fires)
|
||||
start_expected_prints_cleanup()
|
||||
|
||||
# Initialize virtual printer manager and sync from DB
|
||||
from backend.app.services.virtual_printer import virtual_printer_manager
|
||||
|
||||
@@ -3491,6 +3811,7 @@ async def lifespan(app: FastAPI):
|
||||
stop_runtime_tracking()
|
||||
stop_spoolbuddy_watchdog()
|
||||
stop_camera_cleanup()
|
||||
stop_expected_prints_cleanup()
|
||||
printer_manager.disconnect_all()
|
||||
await close_spoolman_client()
|
||||
|
||||
@@ -3686,6 +4007,7 @@ app.include_router(background_dispatch_routes.router, prefix=app_settings.api_pr
|
||||
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(user_notifications.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)
|
||||
|
||||
@@ -170,4 +170,29 @@ DEFAULT_TEMPLATES = [
|
||||
"title_template": "{app_name} - Password Reset",
|
||||
"body_template": "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
|
||||
},
|
||||
# User email notification templates (sent to the print job owner)
|
||||
{
|
||||
"event_type": "user_print_start",
|
||||
"name": "User Print Started",
|
||||
"title_template": "Your Print Has Started",
|
||||
"body_template": "Hello {username},\n\nYour print job has started on {printer}.\n\nFile: {filename}\n\nYou will be notified when it completes.",
|
||||
},
|
||||
{
|
||||
"event_type": "user_print_complete",
|
||||
"name": "User Print Completed",
|
||||
"title_template": "Your Print Is Complete",
|
||||
"body_template": "Hello {username},\n\nYour print job has completed on {printer}.\n\nFile: {filename}",
|
||||
},
|
||||
{
|
||||
"event_type": "user_print_failed",
|
||||
"name": "User Print Failed",
|
||||
"title_template": "Your Print Has Failed",
|
||||
"body_template": "Hello {username},\n\nYour print job has failed on {printer}.\n\nFile: {filename}",
|
||||
},
|
||||
{
|
||||
"event_type": "user_print_stopped",
|
||||
"name": "User Print Stopped",
|
||||
"title_template": "Your Print Has Been Stopped",
|
||||
"body_template": "Hello {username},\n\nYour print job was stopped on {printer}.\n\nFile: {filename}",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ from backend.app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.user_email_pref import UserEmailPreference
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -45,6 +46,15 @@ class User(Base):
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Relationship to email notification preferences
|
||||
email_preferences: Mapped[UserEmailPreference | None] = relationship(
|
||||
"UserEmailPreference",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user is an admin.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""User email notification preference model."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class UserEmailPreference(Base):
|
||||
"""Stores per-user email notification preferences for their own print jobs."""
|
||||
|
||||
__tablename__ = "user_email_preferences"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True
|
||||
)
|
||||
|
||||
# Print lifecycle notifications (only for jobs submitted by this user)
|
||||
notify_print_start: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
notify_print_complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
notify_print_failed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
notify_print_stopped: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
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())
|
||||
|
||||
# Relationship
|
||||
user: Mapped["User"] = relationship(back_populates="email_preferences")
|
||||
@@ -81,6 +81,11 @@ EVENT_VARIABLES: dict[str, list[str]] = {
|
||||
# User management notifications
|
||||
"user_created": ["username", "password", "login_url", "app_name", "timestamp"],
|
||||
"password_reset": ["username", "password", "login_url", "app_name", "timestamp"],
|
||||
# User email print notifications
|
||||
"user_print_start": ["username", "printer", "filename", "timestamp", "app_name"],
|
||||
"user_print_complete": ["username", "printer", "filename", "timestamp", "app_name"],
|
||||
"user_print_failed": ["username", "printer", "filename", "timestamp", "app_name"],
|
||||
"user_print_stopped": ["username", "printer", "filename", "timestamp", "app_name"],
|
||||
}
|
||||
|
||||
# Sample data for previewing templates
|
||||
@@ -252,6 +257,35 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
|
||||
"app_name": "Bambuddy",
|
||||
"timestamp": "2024-01-15 14:30",
|
||||
},
|
||||
# User email print notifications
|
||||
"user_print_start": {
|
||||
"username": "john_doe",
|
||||
"printer": "Bambu X1C",
|
||||
"filename": "Benchy.3mf",
|
||||
"timestamp": "2024-01-15 14:30",
|
||||
"app_name": "Bambuddy",
|
||||
},
|
||||
"user_print_complete": {
|
||||
"username": "john_doe",
|
||||
"printer": "Bambu X1C",
|
||||
"filename": "Benchy.3mf",
|
||||
"timestamp": "2024-01-15 15:48",
|
||||
"app_name": "Bambuddy",
|
||||
},
|
||||
"user_print_failed": {
|
||||
"username": "john_doe",
|
||||
"printer": "Bambu X1C",
|
||||
"filename": "Benchy.3mf",
|
||||
"timestamp": "2024-01-15 15:15",
|
||||
"app_name": "Bambuddy",
|
||||
},
|
||||
"user_print_stopped": {
|
||||
"username": "john_doe",
|
||||
"printer": "Bambu X1C",
|
||||
"filename": "Benchy.3mf",
|
||||
"timestamp": "2024-01-15 15:15",
|
||||
"app_name": "Bambuddy",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -178,6 +178,12 @@ class AppSettings(BaseModel):
|
||||
description="Low stock threshold percentage (%) for inventory filtering and display",
|
||||
)
|
||||
|
||||
# User email notifications (requires Advanced Authentication)
|
||||
user_notifications_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable user email notifications for print job events (requires Advanced Authentication)",
|
||||
)
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
"""Schema for updating settings (all fields optional)."""
|
||||
@@ -245,3 +251,4 @@ class AppSettingsUpdate(BaseModel):
|
||||
prometheus_enabled: bool | None = None
|
||||
prometheus_token: str | None = None
|
||||
low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
|
||||
user_notifications_enabled: bool | None = None
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Schemas for user email notification preferences."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserEmailPreferenceResponse(BaseModel):
|
||||
"""Response schema for user email notification preferences."""
|
||||
|
||||
notify_print_start: bool
|
||||
notify_print_complete: bool
|
||||
notify_print_failed: bool
|
||||
notify_print_stopped: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserEmailPreferenceUpdate(BaseModel):
|
||||
"""Update schema for user email notification preferences."""
|
||||
|
||||
notify_print_start: bool
|
||||
notify_print_complete: bool
|
||||
notify_print_failed: bool
|
||||
notify_print_stopped: bool
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
import secrets
|
||||
import smtplib
|
||||
import string
|
||||
from datetime import datetime
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any
|
||||
@@ -514,3 +515,68 @@ async def create_password_reset_email_from_template(
|
||||
# Fallback to hardcoded template
|
||||
logger.warning("No password reset email template found in database, using default")
|
||||
return create_password_reset_email(username, password, login_url)
|
||||
|
||||
|
||||
async def send_user_print_notification(
|
||||
db: AsyncSession,
|
||||
event_type: str,
|
||||
user_email: str,
|
||||
username: str,
|
||||
variables: dict,
|
||||
) -> None:
|
||||
"""Send a print notification email to a user using Advanced Auth SMTP settings.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
event_type: One of 'user_print_start', 'user_print_complete', 'user_print_failed', 'user_print_stopped'
|
||||
user_email: Recipient email address
|
||||
username: Username of the recipient
|
||||
variables: Template variables (printer, filename, etc.)
|
||||
"""
|
||||
# Check that advanced auth is enabled (SMTP settings must be configured)
|
||||
smtp_settings = await get_smtp_settings(db)
|
||||
if not smtp_settings:
|
||||
logger.warning("Cannot send user print notification: SMTP settings not configured")
|
||||
return
|
||||
|
||||
# Get the template
|
||||
template = await get_notification_template(db, event_type)
|
||||
if template is None:
|
||||
logger.warning("No template found for event type: %s", event_type)
|
||||
return
|
||||
|
||||
# Add common variables (username, timestamp, app_name) merged with caller-supplied variables
|
||||
all_variables = {
|
||||
"username": username,
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"app_name": "Bambuddy",
|
||||
**variables,
|
||||
}
|
||||
|
||||
subject = render_template(template.title_template, all_variables)
|
||||
text_body = render_template(template.body_template, all_variables)
|
||||
|
||||
# Build HTML body — content comes entirely from the database template
|
||||
escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
|
||||
html_body = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #1db954 0%, #158a3e 100%); background-color: #1db954; padding: 20px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
|
||||
</div>
|
||||
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
|
||||
<div style="font-size: 16px;">{escaped_text_body}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
try:
|
||||
send_email(smtp_settings, user_email, subject, text_body, html_body)
|
||||
logger.info("Sent %s notification email to %s", event_type, user_email)
|
||||
except Exception as e:
|
||||
logger.error("Failed to send %s notification to %s: %s", event_type, user_email, e)
|
||||
|
||||
@@ -1179,6 +1179,120 @@ class NotificationService:
|
||||
"""Clear the template cache. Call this when templates are updated."""
|
||||
self._template_cache.clear()
|
||||
|
||||
async def send_user_print_email(
|
||||
self,
|
||||
event_type: str,
|
||||
created_by_id: int | None,
|
||||
printer_name: str,
|
||||
filename: str,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Send a print event email notification to the user who submitted the job.
|
||||
|
||||
Args:
|
||||
event_type: 'user_print_start', 'user_print_complete', 'user_print_failed', or 'user_print_stopped'
|
||||
created_by_id: User ID who submitted the print job (from archive)
|
||||
printer_name: Name of the printer
|
||||
filename: Raw filename or subtask name
|
||||
db: Database session
|
||||
"""
|
||||
if created_by_id is None:
|
||||
logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type)
|
||||
return
|
||||
|
||||
try:
|
||||
# Check if advanced auth is enabled - required for user email notifications
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting or setting.value.lower() != "true":
|
||||
logger.debug("[EMAIL] Skipping user print email (%s): advanced_auth not enabled", event_type)
|
||||
return
|
||||
|
||||
# Check if user notifications are enabled (admin-controlled toggle)
|
||||
notif_enabled_result = await db.execute(
|
||||
select(Settings).where(Settings.key == "user_notifications_enabled")
|
||||
)
|
||||
notif_enabled_setting = notif_enabled_result.scalar_one_or_none()
|
||||
if notif_enabled_setting and notif_enabled_setting.value.lower() == "false":
|
||||
logger.debug("[EMAIL] Skipping user print email (%s): user_notifications_enabled is false", event_type)
|
||||
return
|
||||
|
||||
# Check SMTP settings are configured - required for sending emails
|
||||
from backend.app.services.email_service import get_smtp_settings, send_user_print_notification
|
||||
|
||||
smtp_settings = await get_smtp_settings(db)
|
||||
if not smtp_settings:
|
||||
logger.debug("[EMAIL] Skipping user print email (%s): SMTP settings not configured", event_type)
|
||||
return
|
||||
|
||||
# Load user preferences
|
||||
from backend.app.models.user import User
|
||||
from backend.app.models.user_email_pref import UserEmailPreference
|
||||
|
||||
user_result = await db.execute(select(User).where(User.id == created_by_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is None or not user.email:
|
||||
logger.debug(
|
||||
"[EMAIL] Skipping user print email (%s): user %s not found or has no email address",
|
||||
event_type,
|
||||
created_by_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Load user's notification preferences
|
||||
pref_result = await db.execute(
|
||||
select(UserEmailPreference).where(UserEmailPreference.user_id == created_by_id)
|
||||
)
|
||||
pref = pref_result.scalar_one_or_none()
|
||||
|
||||
# Determine if this event type should be sent
|
||||
should_send = False
|
||||
if event_type == "user_print_start":
|
||||
should_send = pref is None or pref.notify_print_start
|
||||
elif event_type == "user_print_complete":
|
||||
should_send = pref is None or pref.notify_print_complete
|
||||
elif event_type == "user_print_failed":
|
||||
should_send = pref is None or pref.notify_print_failed
|
||||
elif event_type == "user_print_stopped":
|
||||
should_send = pref is None or pref.notify_print_stopped
|
||||
|
||||
if not should_send:
|
||||
logger.debug(
|
||||
"[EMAIL] Skipping user print email (%s): user %s has notifications disabled for this event",
|
||||
event_type,
|
||||
created_by_id,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[EMAIL] Sending user print email: event=%s, user=%s (%s), printer=%s, file=%s",
|
||||
event_type,
|
||||
user.username,
|
||||
user.email,
|
||||
printer_name,
|
||||
filename,
|
||||
)
|
||||
|
||||
# Build variables
|
||||
variables = {
|
||||
"printer": printer_name,
|
||||
"filename": self._clean_filename(filename),
|
||||
}
|
||||
|
||||
# Send the email
|
||||
await send_user_print_notification(
|
||||
db=db,
|
||||
event_type=event_type,
|
||||
user_email=user.email,
|
||||
username=user.username,
|
||||
variables=variables,
|
||||
)
|
||||
logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send user print email notification: %s", e, exc_info=True)
|
||||
|
||||
# ==================== Queue Notifications ====================
|
||||
|
||||
async def on_queue_job_added(
|
||||
|
||||
@@ -1517,6 +1517,7 @@ class PrintScheduler:
|
||||
printer_id=item.printer_id,
|
||||
source_file=file_path,
|
||||
original_filename=filename,
|
||||
created_by_id=item.created_by_id,
|
||||
)
|
||||
if archive:
|
||||
item.archive_id = archive.id
|
||||
@@ -1650,7 +1651,13 @@ class PrintScheduler:
|
||||
if archive:
|
||||
from backend.app.main import register_expected_print
|
||||
|
||||
register_expected_print(item.printer_id, remote_filename, archive.id, ams_mapping=ams_mapping)
|
||||
register_expected_print(
|
||||
item.printer_id,
|
||||
remote_filename,
|
||||
archive.id,
|
||||
ams_mapping=ams_mapping,
|
||||
created_by_id=item.created_by_id,
|
||||
)
|
||||
|
||||
# IMPORTANT: Set status to "printing" BEFORE sending the print command.
|
||||
# This prevents phantom reprints if the backend crashes/restarts after the
|
||||
|
||||
@@ -19,6 +19,7 @@ import InventoryPage from './pages/InventoryPage';
|
||||
import { SystemInfoPage } from './pages/SystemInfoPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { SetupPage } from './pages/SetupPage';
|
||||
import { NotificationsPage } from './pages/NotificationsPage';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
@@ -146,6 +147,7 @@ function App() {
|
||||
<Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
|
||||
<Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
|
||||
<Route path="system" element={<SystemInfoPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="external/:id" element={<ExternalLinkPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -860,6 +860,8 @@ export interface AppSettings {
|
||||
bed_cooled_threshold: number;
|
||||
// Inventory low stock threshold
|
||||
low_stock_threshold: number;
|
||||
// User email notifications toggle
|
||||
user_notifications_enabled: boolean;
|
||||
}
|
||||
|
||||
export type AppSettingsUpdate = Partial<AppSettings>;
|
||||
@@ -2051,7 +2053,7 @@ export type Permission =
|
||||
| 'camera:view'
|
||||
| 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
|
||||
| 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete'
|
||||
| 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete'
|
||||
| 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete' | 'notifications:user_email'
|
||||
| 'notification_templates:read' | 'notification_templates:update'
|
||||
| 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete'
|
||||
| 'discovery:scan'
|
||||
@@ -2115,6 +2117,14 @@ export interface PermissionsListResponse {
|
||||
all_permissions: Permission[];
|
||||
}
|
||||
|
||||
// User email notification preferences
|
||||
export interface UserEmailPreferences {
|
||||
notify_print_start: boolean;
|
||||
notify_print_complete: boolean;
|
||||
notify_print_failed: boolean;
|
||||
notify_print_stopped: boolean;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
@@ -2294,6 +2304,15 @@ export const api = {
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
}),
|
||||
|
||||
// User Email Notifications
|
||||
getUserEmailPreferences: () =>
|
||||
request<UserEmailPreferences>('/user-notifications/preferences'),
|
||||
updateUserEmailPreferences: (data: UserEmailPreferences) =>
|
||||
request<UserEmailPreferences>('/user-notifications/preferences', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Groups
|
||||
getPermissions: () => request<PermissionsListResponse>('/groups/permissions'),
|
||||
getGroups: () => request<Group[]>('/groups/'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, type LucideIcon } from 'lucide-react';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
@@ -25,6 +25,7 @@ interface NavItem {
|
||||
}
|
||||
|
||||
export const defaultNavItems: NavItem[] = [
|
||||
// Primary workflow items
|
||||
{ id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
|
||||
{ id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
|
||||
{ id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
|
||||
@@ -34,6 +35,8 @@ export const defaultNavItems: NavItem[] = [
|
||||
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
{ id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
|
||||
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
|
||||
// User-account features: kept adjacent to Settings intentionally
|
||||
{ id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
|
||||
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
];
|
||||
|
||||
@@ -114,6 +117,14 @@ export function Layout() {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Check advanced auth status for conditional nav items
|
||||
const { data: advancedAuthStatus } = useQuery({
|
||||
queryKey: ['advancedAuthStatus'],
|
||||
queryFn: api.getAdvancedAuthStatus,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: authEnabled,
|
||||
});
|
||||
|
||||
const { data: updateCheck } = useQuery({
|
||||
queryKey: ['updateCheck'],
|
||||
queryFn: api.checkForUpdates,
|
||||
@@ -234,10 +245,15 @@ export function Layout() {
|
||||
inventory: 'inventory:read',
|
||||
files: 'library:read',
|
||||
settings: 'settings:read',
|
||||
notifications: 'notifications:user_email',
|
||||
};
|
||||
|
||||
const isHidden = (id: string) =>
|
||||
authEnabled && id in navPermissions && !hasPermission(navPermissions[id]);
|
||||
const isHidden = (id: string) => {
|
||||
if (authEnabled && id in navPermissions && !hasPermission(navPermissions[id])) return true;
|
||||
// notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting
|
||||
if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Add items in stored order
|
||||
for (const id of sidebarOrder) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
projects: 'Projekte',
|
||||
inventory: 'Filament',
|
||||
files: 'Dateimanager',
|
||||
notifications: 'Benachrichtigungen',
|
||||
settings: 'Einstellungen',
|
||||
system: 'System',
|
||||
collapseSidebar: 'Seitenleiste einklappen',
|
||||
@@ -1286,6 +1287,9 @@ export default {
|
||||
notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',
|
||||
bedCooledThreshold: 'Bett-Abkühlung Schwellenwert',
|
||||
bedCooledThresholdDescription: 'Temperatur, unter der das Bett nach einem Druck als abgekühlt gilt',
|
||||
userNotificationsEnabled: 'Benutzerbenachrichtigungen',
|
||||
userNotificationsEnabledDescription: 'Aktiviert das Benutzerbenachrichtigungsmenü und E-Mail-Benachrichtigungen für Druckereignisse. Erfordert Erweiterte Authentifizierung.',
|
||||
userNotificationsDisabledHint: 'Erweiterte Authentifizierung aktivieren, um Benutzerbenachrichtigungen zu verwenden.',
|
||||
notificationProviders: 'Benachrichtigungsanbieter',
|
||||
addProvider: 'Anbieter hinzufügen',
|
||||
editProvider: 'Anbieter bearbeiten',
|
||||
@@ -3927,6 +3931,26 @@ export default {
|
||||
maintenance_due: 'Wartung fällig',
|
||||
test: 'Test',
|
||||
},
|
||||
// User email notification preferences
|
||||
userEmail: {
|
||||
title: 'Benachrichtigungen',
|
||||
emailNotifications: 'E-Mail-Benachrichtigungen',
|
||||
emailNotificationsDesc: 'Erhalten Sie E-Mail-Benachrichtigungen für Ihre eigenen Druckaufträge. E-Mails werden über die in der erweiterten Authentifizierung konfigurierten SMTP-Einstellungen gesendet.',
|
||||
sendingTo: 'Benachrichtigungen werden gesendet an',
|
||||
noEmailWarning: 'Ihr Konto hat keine E-Mail-Adresse. Wenden Sie sich an einen Administrator, um eine hinzuzufügen.',
|
||||
printJobNotifications: 'Druckauftrags-Benachrichtigungen',
|
||||
printJobNotificationsDesc: 'Wählen Sie aus, welche Ereignisse E-Mail-Benachrichtigungen für von Ihnen gesendete Druckaufträge auslösen.',
|
||||
printJobStarts: 'Druckauftrag startet',
|
||||
printJobStartsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag beginnt.',
|
||||
printJobFinishes: 'Druckauftrag fertig',
|
||||
printJobFinishesDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag erfolgreich abgeschlossen wurde.',
|
||||
printErrors: 'Druckfehler',
|
||||
printErrorsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag fehlschlägt oder auf einen Fehler stößt.',
|
||||
printJobStops: 'Druckauftrag gestoppt',
|
||||
printJobStopsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag abgebrochen oder gestoppt wird.',
|
||||
saveSuccess: 'Benachrichtigungseinstellungen gespeichert.',
|
||||
saveError: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
|
||||
},
|
||||
},
|
||||
|
||||
// Rich Text Editor
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
projects: 'Projects',
|
||||
inventory: 'Filament',
|
||||
files: 'File Manager',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Settings',
|
||||
system: 'System',
|
||||
collapseSidebar: 'Collapse sidebar',
|
||||
@@ -1286,6 +1287,9 @@ export default {
|
||||
notificationLanguageDescription: 'Language for push notifications',
|
||||
bedCooledThreshold: 'Bed Cooled Threshold',
|
||||
bedCooledThresholdDescription: 'Temperature below which the bed is considered cooled after a print',
|
||||
userNotificationsEnabled: 'User Notifications',
|
||||
userNotificationsEnabledDescription: 'Enable the user notifications menu and email notifications for print job events. Requires Advanced Authentication.',
|
||||
userNotificationsDisabledHint: 'Enable Advanced Authentication to use user notifications.',
|
||||
notificationProviders: 'Notification Providers',
|
||||
addProvider: 'Add Provider',
|
||||
editProvider: 'Edit Provider',
|
||||
@@ -3932,6 +3936,26 @@ export default {
|
||||
maintenance_due: 'Maintenance Due',
|
||||
test: 'Test',
|
||||
},
|
||||
// User email notification preferences
|
||||
userEmail: {
|
||||
title: 'Notifications',
|
||||
emailNotifications: 'Email Notifications',
|
||||
emailNotificationsDesc: 'Receive email notifications for your own print jobs. Emails are sent using the system SMTP settings configured in Advanced Authentication.',
|
||||
sendingTo: 'Notifications will be sent to',
|
||||
noEmailWarning: 'Your account does not have an email address. Contact an administrator to add one.',
|
||||
printJobNotifications: 'Print Job Notifications',
|
||||
printJobNotificationsDesc: 'Choose which events trigger email notifications for print jobs you submit.',
|
||||
printJobStarts: 'Print Job Starts',
|
||||
printJobStartsDesc: 'Get notified when your print job begins.',
|
||||
printJobFinishes: 'Print Job Finishes',
|
||||
printJobFinishesDesc: 'Get notified when your print job completes successfully.',
|
||||
printErrors: 'Print Errors',
|
||||
printErrorsDesc: 'Get notified when your print job fails or encounters an error.',
|
||||
printJobStops: 'Print Job Stops',
|
||||
printJobStopsDesc: 'Get notified when your print job is cancelled or stopped.',
|
||||
saveSuccess: 'Notification preferences saved.',
|
||||
saveError: 'Failed to save notification preferences.',
|
||||
},
|
||||
},
|
||||
|
||||
// Rich Text Editor
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
projects: 'Projets',
|
||||
inventory: 'Filament',
|
||||
files: 'Gestionnaire de fichiers',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Paramètres',
|
||||
system: 'Système',
|
||||
collapseSidebar: 'Réduire la barre latérale',
|
||||
@@ -1286,6 +1287,9 @@ export default {
|
||||
notificationLanguageDescription: 'Langue pour les notifications push',
|
||||
bedCooledThreshold: 'Seuil de refroidissement du plateau',
|
||||
bedCooledThresholdDescription: 'Température en dessous de laquelle le plateau est considéré comme refroidi',
|
||||
userNotificationsEnabled: 'Notifications utilisateur',
|
||||
userNotificationsEnabledDescription: "Active le menu de notifications utilisateur et les notifications par e-mail pour les événements d'impression. Nécessite l'authentification avancée.",
|
||||
userNotificationsDisabledHint: "Activez l'authentification avancée pour utiliser les notifications utilisateur.",
|
||||
notificationProviders: 'Fournisseurs de notifications',
|
||||
addProvider: 'Ajouter un fournisseur',
|
||||
editProvider: 'Modifier le fournisseur',
|
||||
@@ -3919,6 +3923,26 @@ export default {
|
||||
maintenance_due: 'Maintenance requise',
|
||||
test: 'Test',
|
||||
},
|
||||
// User email notification preferences
|
||||
userEmail: {
|
||||
title: 'Notifications',
|
||||
emailNotifications: 'Notifications par e-mail',
|
||||
emailNotificationsDesc: "Recevez des notifications par e-mail pour vos propres travaux d'impression. Les e-mails sont envoyés via les paramètres SMTP configurés dans l'authentification avancée.",
|
||||
sendingTo: 'Les notifications seront envoyées à',
|
||||
noEmailWarning: "Votre compte n'a pas d'adresse e-mail. Contactez un administrateur pour en ajouter une.",
|
||||
printJobNotifications: "Notifications de travaux d'impression",
|
||||
printJobNotificationsDesc: "Choisissez quels événements déclenchent des notifications par e-mail pour les travaux d'impression que vous soumettez.",
|
||||
printJobStarts: "Démarrage du travail d'impression",
|
||||
printJobStartsDesc: "Être notifié quand votre travail d'impression commence.",
|
||||
printJobFinishes: "Fin du travail d'impression",
|
||||
printJobFinishesDesc: "Être notifié quand votre travail d'impression se termine avec succès.",
|
||||
printErrors: "Erreurs d'impression",
|
||||
printErrorsDesc: "Être notifié quand votre travail d'impression échoue ou rencontre une erreur.",
|
||||
printJobStops: "Travail d'impression arrêté",
|
||||
printJobStopsDesc: "Être notifié quand votre travail d'impression est annulé ou arrêté.",
|
||||
saveSuccess: 'Préférences de notification sauvegardées.',
|
||||
saveError: 'Impossible de sauvegarder les préférences de notification.',
|
||||
},
|
||||
},
|
||||
|
||||
// Rich Text Editor
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
projects: 'Progetti',
|
||||
inventory: 'Filamento',
|
||||
files: 'File',
|
||||
notifications: 'Notifiche',
|
||||
settings: 'Impostazioni',
|
||||
system: 'Sistema',
|
||||
collapseSidebar: 'Comprimi barra laterale',
|
||||
@@ -1286,6 +1287,9 @@ export default {
|
||||
notificationLanguageDescription: 'Lingua per notifiche push',
|
||||
bedCooledThreshold: 'Soglia raffreddamento piatto',
|
||||
bedCooledThresholdDescription: 'Temperatura sotto la quale il piatto è considerato raffreddato dopo una stampa',
|
||||
userNotificationsEnabled: 'Notifiche utente',
|
||||
userNotificationsEnabledDescription: "Abilita il menu notifiche utente e le notifiche e-mail per gli eventi di stampa. Richiede l'autenticazione avanzata.",
|
||||
userNotificationsDisabledHint: "Abilita l'autenticazione avanzata per usare le notifiche utente.",
|
||||
notificationProviders: 'Provider notifiche',
|
||||
addProvider: 'Aggiungi provider',
|
||||
editProvider: 'Modifica provider',
|
||||
@@ -3918,6 +3922,26 @@ export default {
|
||||
maintenance_due: 'Manutenzione necessaria',
|
||||
test: 'Prova',
|
||||
},
|
||||
// User email notification preferences
|
||||
userEmail: {
|
||||
title: 'Notifiche',
|
||||
emailNotifications: 'Notifiche via e-mail',
|
||||
emailNotificationsDesc: "Ricevi notifiche via e-mail per i tuoi lavori di stampa. Le e-mail vengono inviate tramite le impostazioni SMTP configurate nell'autenticazione avanzata.",
|
||||
sendingTo: 'Le notifiche verranno inviate a',
|
||||
noEmailWarning: "Il tuo account non ha un indirizzo e-mail. Contatta un amministratore per aggiungerne uno.",
|
||||
printJobNotifications: 'Notifiche lavori di stampa',
|
||||
printJobNotificationsDesc: 'Scegli quali eventi attivano le notifiche e-mail per i lavori di stampa che invii.',
|
||||
printJobStarts: 'Inizio lavoro di stampa',
|
||||
printJobStartsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa inizia.',
|
||||
printJobFinishes: 'Fine lavoro di stampa',
|
||||
printJobFinishesDesc: 'Ricevi una notifica quando il tuo lavoro di stampa si completa correttamente.',
|
||||
printErrors: 'Errori di stampa',
|
||||
printErrorsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa fallisce o incontra un errore.',
|
||||
printJobStops: 'Lavoro di stampa interrotto',
|
||||
printJobStopsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa viene annullato o interrotto.',
|
||||
saveSuccess: 'Preferenze di notifica salvate.',
|
||||
saveError: 'Impossibile salvare le preferenze di notifica.',
|
||||
},
|
||||
},
|
||||
|
||||
// Rich Text Editor
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
projects: 'プロジェクト',
|
||||
inventory: 'フィラメント',
|
||||
files: 'ファイル管理',
|
||||
notifications: '通知',
|
||||
settings: '設定',
|
||||
system: 'システム',
|
||||
collapseSidebar: 'サイドバーを閉じる',
|
||||
@@ -1285,6 +1286,9 @@ export default {
|
||||
notificationLanguageDescription: 'プッシュ通知の言語',
|
||||
bedCooledThreshold: 'ベッド冷却しきい値',
|
||||
bedCooledThresholdDescription: '印刷後にベッドが冷却されたと見なす温度',
|
||||
userNotificationsEnabled: 'ユーザー通知',
|
||||
userNotificationsEnabledDescription: 'ユーザー通知メニューと印刷ジョブイベントのメール通知を有効にします。高度な認証が必要です。',
|
||||
userNotificationsDisabledHint: 'ユーザー通知を使用するには高度な認証を有効にしてください。',
|
||||
notificationProviders: '通知プロバイダー',
|
||||
addProvider: 'プロバイダーを追加',
|
||||
editProvider: 'プロバイダーを編集',
|
||||
@@ -3931,6 +3935,26 @@ export default {
|
||||
maintenance_due: 'メンテナンス期限',
|
||||
test: 'テスト',
|
||||
},
|
||||
// User email notification preferences
|
||||
userEmail: {
|
||||
title: '通知',
|
||||
emailNotifications: 'メール通知',
|
||||
emailNotificationsDesc: '自分の印刷ジョブに対してメール通知を受け取ります。メールは高度な認証で設定されたSMTP設定を使用して送信されます。',
|
||||
sendingTo: '通知の送信先',
|
||||
noEmailWarning: 'アカウントにメールアドレスが設定されていません。管理者に連絡して追加してもらってください。',
|
||||
printJobNotifications: '印刷ジョブ通知',
|
||||
printJobNotificationsDesc: '送信した印刷ジョブのどのイベントでメール通知を送るかを選択します。',
|
||||
printJobStarts: '印刷ジョブ開始',
|
||||
printJobStartsDesc: '印刷ジョブが開始されたときに通知を受け取る。',
|
||||
printJobFinishes: '印刷ジョブ完了',
|
||||
printJobFinishesDesc: '印刷ジョブが正常に完了したときに通知を受け取る。',
|
||||
printErrors: '印刷エラー',
|
||||
printErrorsDesc: '印刷ジョブが失敗またはエラーが発生したときに通知を受け取る。',
|
||||
printJobStops: '印刷ジョブ停止',
|
||||
printJobStopsDesc: '印刷ジョブがキャンセルまたは停止されたときに通知を受け取る。',
|
||||
saveSuccess: '通知設定を保存しました。',
|
||||
saveError: '通知設定の保存に失敗しました。',
|
||||
},
|
||||
},
|
||||
|
||||
// Rich Text Editor
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
projects: 'Projetos',
|
||||
inventory: 'Inventário',
|
||||
files: 'Gerenciador de Arquivos',
|
||||
notifications: 'Notificações',
|
||||
settings: 'Configurações',
|
||||
system: 'Sistema',
|
||||
collapseSidebar: 'Recolher barra lateral',
|
||||
@@ -1286,6 +1287,9 @@ export default {
|
||||
notificationLanguageDescription: 'Idioma para notificações push',
|
||||
bedCooledThreshold: 'Limite de Resfriamento da Cama',
|
||||
bedCooledThresholdDescription: 'Temperatura abaixo da qual a cama é considerada resfriada após uma impressão',
|
||||
userNotificationsEnabled: 'Notificações do Usuário',
|
||||
userNotificationsEnabledDescription: 'Ativa o menu de notificações do usuário e notificações por e-mail para eventos de impressão. Requer Autenticação Avançada.',
|
||||
userNotificationsDisabledHint: 'Ative a Autenticação Avançada para usar as notificações do usuário.',
|
||||
notificationProviders: 'Provedores de Notificação',
|
||||
addProvider: 'Adicionar Provedor',
|
||||
editProvider: 'Editar Provedor',
|
||||
@@ -3918,6 +3922,26 @@ export default {
|
||||
maintenance_due: 'Manutenção Necessária',
|
||||
test: 'Teste',
|
||||
},
|
||||
// User email notification preferences
|
||||
userEmail: {
|
||||
title: 'Notificações',
|
||||
emailNotifications: 'Notificações por E-mail',
|
||||
emailNotificationsDesc: 'Receba notificações por e-mail para seus próprios trabalhos de impressão. Os e-mails são enviados usando as configurações SMTP definidas na Autenticação Avançada.',
|
||||
sendingTo: 'As notificações serão enviadas para',
|
||||
noEmailWarning: 'Sua conta não tem um endereço de e-mail. Entre em contato com um administrador para adicionar um.',
|
||||
printJobNotifications: 'Notificações de Trabalhos de Impressão',
|
||||
printJobNotificationsDesc: 'Escolha quais eventos acionam notificações por e-mail para os trabalhos de impressão que você envia.',
|
||||
printJobStarts: 'Início do Trabalho de Impressão',
|
||||
printJobStartsDesc: 'Ser notificado quando seu trabalho de impressão começar.',
|
||||
printJobFinishes: 'Conclusão do Trabalho de Impressão',
|
||||
printJobFinishesDesc: 'Ser notificado quando seu trabalho de impressão concluir com sucesso.',
|
||||
printErrors: 'Erros de Impressão',
|
||||
printErrorsDesc: 'Ser notificado quando seu trabalho de impressão falhar ou encontrar um erro.',
|
||||
printJobStops: 'Trabalho de Impressão Parado',
|
||||
printJobStopsDesc: 'Ser notificado quando seu trabalho de impressão for cancelado ou parado.',
|
||||
saveSuccess: 'Preferências de notificação salvas.',
|
||||
saveError: 'Falha ao salvar preferências de notificação.',
|
||||
},
|
||||
},
|
||||
|
||||
// Rich Text Editor
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
projects: '项目',
|
||||
inventory: '耗材',
|
||||
files: '文件管理器',
|
||||
notifications: '通知',
|
||||
settings: '设置',
|
||||
system: '系统',
|
||||
collapseSidebar: '收起侧边栏',
|
||||
@@ -1286,6 +1287,9 @@ export default {
|
||||
notificationLanguageDescription: '推送通知的语言',
|
||||
bedCooledThreshold: '热床冷却阈值',
|
||||
bedCooledThresholdDescription: '打印后热床被视为已冷却的温度',
|
||||
userNotificationsEnabled: '用户通知',
|
||||
userNotificationsEnabledDescription: '启用用户通知菜单和打印任务事件的邮件通知。需要高级身份验证。',
|
||||
userNotificationsDisabledHint: '请启用高级身份验证以使用用户通知。',
|
||||
notificationProviders: '通知提供商',
|
||||
addProvider: '添加提供商',
|
||||
editProvider: '编辑提供商',
|
||||
@@ -3918,6 +3922,25 @@ export default {
|
||||
maintenance_due: '需要维护',
|
||||
test: '测试',
|
||||
},
|
||||
userEmail: {
|
||||
title: '通知',
|
||||
emailNotifications: '邮件通知',
|
||||
emailNotificationsDesc: '接收您自己打印任务的邮件通知。邮件将通过高级身份验证中配置的 SMTP 设置发送。',
|
||||
sendingTo: '通知将发送至',
|
||||
noEmailWarning: '您的账户没有邮件地址。请联系管理员添加。',
|
||||
printJobNotifications: '打印任务通知',
|
||||
printJobNotificationsDesc: '选择哪些事件会触发您提交的打印任务的邮件通知。',
|
||||
printJobStarts: '打印任务开始',
|
||||
printJobStartsDesc: '当您的打印任务开始时收到通知。',
|
||||
printJobFinishes: '打印任务完成',
|
||||
printJobFinishesDesc: '当您的打印任务成功完成时收到通知。',
|
||||
printErrors: '打印错误',
|
||||
printErrorsDesc: '当您的打印任务失败或遇到错误时收到通知。',
|
||||
printJobStops: '打印任务停止',
|
||||
printJobStopsDesc: '当您的打印任务被取消或停止时收到通知。',
|
||||
saveSuccess: '通知偏好设置已保存。',
|
||||
saveError: '保存通知偏好设置失败。',
|
||||
},
|
||||
},
|
||||
|
||||
// Rich Text Editor
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, CheckCircle2, Loader2, Mail, Save } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { Card, CardContent, CardHeader } from '../components/Card';
|
||||
|
||||
export function NotificationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [notifyPrintStart, setNotifyPrintStart] = useState(true);
|
||||
const [notifyPrintComplete, setNotifyPrintComplete] = useState(true);
|
||||
const [notifyPrintFailed, setNotifyPrintFailed] = useState(true);
|
||||
const [notifyPrintStopped, setNotifyPrintStopped] = useState(true);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Check advanced auth status - redirect if disabled
|
||||
const { data: advancedAuthStatus, isLoading: isAdvancedAuthLoading } = useQuery({
|
||||
queryKey: ['advancedAuthStatus'],
|
||||
queryFn: api.getAdvancedAuthStatus,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const { data: settings, isLoading: isSettingsLoading } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Fetch current preferences
|
||||
const { data: preferences, isLoading } = useQuery({
|
||||
queryKey: ['user-email-preferences'],
|
||||
queryFn: () => api.getUserEmailPreferences(),
|
||||
});
|
||||
|
||||
// Redirect to settings if Advanced Auth is disabled
|
||||
useEffect(() => {
|
||||
if ((advancedAuthStatus && !advancedAuthStatus.advanced_auth_enabled) || (settings && !settings.user_notifications_enabled)) {
|
||||
navigate('/settings', { replace: true });
|
||||
}
|
||||
}, [advancedAuthStatus, settings, navigate]);
|
||||
|
||||
// Populate form when preferences load
|
||||
useEffect(() => {
|
||||
if (preferences) {
|
||||
setNotifyPrintStart(preferences.notify_print_start);
|
||||
setNotifyPrintComplete(preferences.notify_print_complete);
|
||||
setNotifyPrintFailed(preferences.notify_print_failed);
|
||||
setNotifyPrintStopped(preferences.notify_print_stopped);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [preferences]);
|
||||
|
||||
// Save preferences
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.updateUserEmailPreferences({
|
||||
notify_print_start: notifyPrintStart,
|
||||
notify_print_complete: notifyPrintComplete,
|
||||
notify_print_failed: notifyPrintFailed,
|
||||
notify_print_stopped: notifyPrintStopped,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-email-preferences'] });
|
||||
setIsDirty(false);
|
||||
showToast(t('notifications.userEmail.saveSuccess'), 'success');
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message || t('notifications.userEmail.saveError'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = (
|
||||
setter: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
value: boolean
|
||||
) => {
|
||||
setter(!value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
if (isLoading || isAdvancedAuthLoading || isSettingsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Bell className="w-7 h-7 text-bambu-green" />
|
||||
<h1 className="text-2xl font-bold text-white">{t('notifications.userEmail.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Info card */}
|
||||
<Card className="mb-6 border-blue-500/30 bg-blue-500/5">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-blue-500/20 flex-shrink-0">
|
||||
<Mail className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{t('notifications.userEmail.emailNotifications')}</h3>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
{t('notifications.userEmail.emailNotificationsDesc')}
|
||||
</p>
|
||||
{user?.email ? (
|
||||
<p className="text-sm text-blue-400 mt-2">
|
||||
{t('notifications.userEmail.sendingTo')}: <strong>{user.email}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-yellow-400 mt-2">
|
||||
{t('notifications.userEmail.noEmailWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preferences card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">{t('notifications.userEmail.printJobNotifications')}</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">{t('notifications.userEmail.printJobNotificationsDesc')}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Print Job Starts */}
|
||||
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintStart ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
|
||||
<CheckCircle2 className={`w-5 h-5 ${notifyPrintStart ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{t('notifications.userEmail.printJobStarts')}</p>
|
||||
<p className="text-sm text-bambu-gray">{t('notifications.userEmail.printJobStartsDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle(setNotifyPrintStart, notifyPrintStart)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
|
||||
notifyPrintStart ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={notifyPrintStart}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
notifyPrintStart ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Print Job Finishes */}
|
||||
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintComplete ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
|
||||
<CheckCircle2 className={`w-5 h-5 ${notifyPrintComplete ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{t('notifications.userEmail.printJobFinishes')}</p>
|
||||
<p className="text-sm text-bambu-gray">{t('notifications.userEmail.printJobFinishesDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle(setNotifyPrintComplete, notifyPrintComplete)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
|
||||
notifyPrintComplete ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={notifyPrintComplete}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
notifyPrintComplete ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Print Errors */}
|
||||
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintFailed ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
|
||||
<CheckCircle2 className={`w-5 h-5 ${notifyPrintFailed ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{t('notifications.userEmail.printErrors')}</p>
|
||||
<p className="text-sm text-bambu-gray">{t('notifications.userEmail.printErrorsDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle(setNotifyPrintFailed, notifyPrintFailed)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
|
||||
notifyPrintFailed ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={notifyPrintFailed}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
notifyPrintFailed ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Print Job Stops */}
|
||||
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintStopped ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
|
||||
<CheckCircle2 className={`w-5 h-5 ${notifyPrintStopped ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{t('notifications.userEmail.printJobStops')}</p>
|
||||
<p className="text-sm text-bambu-gray">{t('notifications.userEmail.printJobStopsDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle(setNotifyPrintStopped, notifyPrintStopped)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
|
||||
notifyPrintStopped ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={notifyPrintStopped}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
notifyPrintStopped ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!isDirty || saveMutation.isPending || !user?.email}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('common.saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
{t('common.save')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -523,6 +523,7 @@ export function SettingsPage() {
|
||||
const updateData: UserUpdate = {
|
||||
username: userFormData.username || undefined,
|
||||
password: userFormData.password || undefined,
|
||||
email: userFormData.email || undefined,
|
||||
role: userFormData.role,
|
||||
group_ids: userFormData.group_ids,
|
||||
};
|
||||
@@ -738,7 +739,8 @@ export function SettingsPage() {
|
||||
(settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
|
||||
(settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
|
||||
settings.prometheus_enabled !== localSettings.prometheus_enabled ||
|
||||
settings.prometheus_token !== localSettings.prometheus_token;
|
||||
settings.prometheus_token !== localSettings.prometheus_token ||
|
||||
(settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true);
|
||||
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
@@ -810,6 +812,7 @@ export function SettingsPage() {
|
||||
preferred_slicer: localSettings.preferred_slicer,
|
||||
prometheus_enabled: localSettings.prometheus_enabled,
|
||||
prometheus_token: localSettings.prometheus_token,
|
||||
user_notifications_enabled: localSettings.user_notifications_enabled,
|
||||
};
|
||||
updateMutation.mutate(settingsToSave);
|
||||
}, 500);
|
||||
@@ -2784,6 +2787,32 @@ export function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Notifications Toggle */}
|
||||
<Card className="mb-4">
|
||||
<CardContent className="py-3">
|
||||
<div className={`flex items-center justify-between ${!advancedAuthStatus?.advanced_auth_enabled ? 'opacity-50' : ''}`}>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{t('settings.userNotificationsEnabled')}</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{!advancedAuthStatus?.advanced_auth_enabled
|
||||
? t('settings.userNotificationsDisabledHint')
|
||||
: t('settings.userNotificationsEnabledDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={localSettings.user_notifications_enabled ?? true}
|
||||
disabled={!advancedAuthStatus?.advanced_auth_enabled}
|
||||
onChange={(e) => updateSetting('user_notifications_enabled', e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green peer-disabled:cursor-not-allowed"></div>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test All Results */}
|
||||
{testAllResult && (
|
||||
<Card className="mb-4">
|
||||
|
||||
Reference in New Issue
Block a user