mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
feat: add system notifications and update checks
Real-time notifications for system events and alerts Per-user notification management and dismissal Update check on startup and every 24 hours to notify users of available versions Notification center UI component Automatic cleanup of expired notifications
This commit is contained in:
parent
da598d3b33
commit
b01eb9585c
20 changed files with 2041 additions and 49 deletions
|
|
@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- Add system notifications and update checks
|
||||
-Real-time notifications for system events and alerts
|
||||
-Per-user notification management and dismissal
|
||||
-Update check on startup and every 24 hours to notify users of available versions
|
||||
-Notification center UI component
|
||||
-Automatic cleanup of expired notifications
|
||||
- Network Access "Reset to Defaults" button: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click.
|
||||
- Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view.
|
||||
- Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from .api_views import (
|
|||
UserAgentViewSet,
|
||||
StreamProfileViewSet,
|
||||
CoreSettingsViewSet,
|
||||
SystemNotificationViewSet,
|
||||
environment,
|
||||
version,
|
||||
rehash_streams_endpoint,
|
||||
|
|
@ -17,6 +18,7 @@ router = DefaultRouter()
|
|||
router.register(r'useragents', UserAgentViewSet, basename='useragent')
|
||||
router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile')
|
||||
router.register(r'settings', CoreSettingsViewSet, basename='coresettings')
|
||||
router.register(r'notifications', SystemNotificationViewSet, basename='systemnotification')
|
||||
urlpatterns = [
|
||||
path('settings/env/', environment, name='token_refresh'),
|
||||
path('version/', version, name='version'),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
from django.db import models
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
|
@ -466,3 +467,159 @@ def get_system_events(request):
|
|||
return Response({
|
||||
'error': 'Failed to fetch system events'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# System Notifications API
|
||||
# ─────────────────────────────
|
||||
from .models import SystemNotification, NotificationDismissal
|
||||
from .serializers import SystemNotificationSerializer, NotificationDismissalSerializer
|
||||
from django.utils import timezone as dj_timezone
|
||||
|
||||
|
||||
class SystemNotificationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for system notifications.
|
||||
Users can view active notifications and dismiss them.
|
||||
Admins can create and manage notifications.
|
||||
"""
|
||||
serializer_class = SystemNotificationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return notifications based on user permissions.
|
||||
Filter out expired and dismissed notifications for regular users.
|
||||
Evaluate conditions for developer notifications.
|
||||
"""
|
||||
from core.developer_notifications import evaluate_conditions
|
||||
from django.core.cache import cache
|
||||
|
||||
user = self.request.user
|
||||
now = dj_timezone.now()
|
||||
|
||||
queryset = SystemNotification.objects.filter(is_active=True)
|
||||
|
||||
# Filter out expired notifications
|
||||
queryset = queryset.filter(
|
||||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)
|
||||
)
|
||||
|
||||
# Filter admin-only notifications for non-admins
|
||||
if not getattr(user, 'is_superuser', False) and getattr(user, 'user_level', 0) < 10:
|
||||
queryset = queryset.filter(admin_only=False)
|
||||
|
||||
# For developer notifications, evaluate conditions
|
||||
# Cache the evaluation per notification to avoid repeated condition checks
|
||||
notifications_to_exclude = []
|
||||
developer_notifications = queryset.filter(source=SystemNotification.Source.DEVELOPER)
|
||||
|
||||
for notification in developer_notifications:
|
||||
action_data = notification.action_data or {}
|
||||
conditions = action_data.get('condition', [])
|
||||
|
||||
if not conditions:
|
||||
continue
|
||||
|
||||
# Cache key based on notification ID and current settings
|
||||
# Cache for 5 minutes to balance freshness with performance
|
||||
cache_key = f'dev_notif_condition_{notification.id}_{user.id}'
|
||||
should_show = cache.get(cache_key)
|
||||
|
||||
if should_show is None:
|
||||
should_show = evaluate_conditions(conditions, user)
|
||||
cache.set(cache_key, should_show, timeout=300) # 5 minutes
|
||||
|
||||
if not should_show:
|
||||
notifications_to_exclude.append(notification.id)
|
||||
|
||||
if notifications_to_exclude:
|
||||
queryset = queryset.exclude(id__in=notifications_to_exclude)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context['request'] = self.request
|
||||
return context
|
||||
|
||||
def list(self, request):
|
||||
"""
|
||||
List all active notifications for the current user.
|
||||
Optionally filter by dismissed status.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Optional: filter out already dismissed notifications
|
||||
include_dismissed = request.query_params.get('include_dismissed', 'false').lower() == 'true'
|
||||
if not include_dismissed:
|
||||
dismissed_ids = NotificationDismissal.objects.filter(
|
||||
user=request.user
|
||||
).values_list('notification_id', flat=True)
|
||||
queryset = queryset.exclude(id__in=dismissed_ids)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response({
|
||||
'notifications': serializer.data,
|
||||
'count': len(serializer.data),
|
||||
'unread_count': queryset.count()
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='dismiss')
|
||||
def dismiss(self, request, pk=None):
|
||||
"""Dismiss a notification for the current user."""
|
||||
notification = self.get_object()
|
||||
action_taken = request.data.get('action_taken', None)
|
||||
|
||||
dismissal, created = NotificationDismissal.objects.get_or_create(
|
||||
user=request.user,
|
||||
notification=notification,
|
||||
defaults={'action_taken': action_taken}
|
||||
)
|
||||
|
||||
if not created and action_taken:
|
||||
dismissal.action_taken = action_taken
|
||||
dismissal.save()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Notification dismissed',
|
||||
'notification_key': notification.notification_key
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='dismiss-all')
|
||||
def dismiss_all(self, request):
|
||||
"""Dismiss all notifications for the current user."""
|
||||
notifications = self.get_queryset()
|
||||
|
||||
# Get notifications not yet dismissed
|
||||
dismissed_ids = NotificationDismissal.objects.filter(
|
||||
user=request.user
|
||||
).values_list('notification_id', flat=True)
|
||||
to_dismiss = notifications.exclude(id__in=dismissed_ids)
|
||||
|
||||
# Create dismissals for all
|
||||
dismissals = [
|
||||
NotificationDismissal(user=request.user, notification=n)
|
||||
for n in to_dismiss
|
||||
]
|
||||
NotificationDismissal.objects.bulk_create(dismissals, ignore_conflicts=True)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'dismissed_count': len(dismissals)
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='count')
|
||||
def unread_count(self, request):
|
||||
"""Get count of unread notifications."""
|
||||
queryset = self.get_queryset()
|
||||
dismissed_ids = NotificationDismissal.objects.filter(
|
||||
user=request.user
|
||||
).values_list('notification_id', flat=True)
|
||||
unread_count = queryset.exclude(id__in=dismissed_ids).count()
|
||||
|
||||
return Response({
|
||||
'unread_count': unread_count
|
||||
})
|
||||
|
||||
|
|
|
|||
49
core/apps.py
49
core/apps.py
|
|
@ -22,3 +22,52 @@ class CoreConfig(AppConfig):
|
|||
def ready(self):
|
||||
# Import signals to ensure they get registered
|
||||
import core.signals
|
||||
|
||||
# Sync developer notifications and check for version updates on startup
|
||||
# Only run in the main process (not in management commands or migrations)
|
||||
import sys
|
||||
if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False:
|
||||
self._sync_developer_notifications()
|
||||
self._check_version_update()
|
||||
|
||||
def _sync_developer_notifications(self):
|
||||
"""Sync developer notifications from JSON file to database."""
|
||||
from django.db import connection
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if tables exist (avoid running during migrations)
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'core_systemnotification'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
# For SQLite
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='core_systemnotification'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
return
|
||||
except Exception:
|
||||
# If we can't check, the table might not exist yet
|
||||
pass
|
||||
|
||||
try:
|
||||
from core.developer_notifications import sync_developer_notifications
|
||||
sync_developer_notifications()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync developer notifications on startup: {e}")
|
||||
|
||||
def _check_version_update(self):
|
||||
"""Check for version updates on startup."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from core.tasks import check_for_version_update
|
||||
check_for_version_update.delay()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check for version updates on startup: {e}")
|
||||
|
|
|
|||
412
core/developer_notifications.py
Normal file
412
core/developer_notifications.py
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
"""
|
||||
Developer Notification Sync Service
|
||||
|
||||
Handles syncing developer-defined notifications from the JSON file to the database.
|
||||
This ensures users receive important notifications from the development team
|
||||
about recommended settings, security updates, and other announcements.
|
||||
|
||||
JSON Schema (see fixtures/developer_notifications.json):
|
||||
{
|
||||
"id": str, # REQUIRED - Unique identifier (notification_key)
|
||||
"title": str, # REQUIRED - Notification heading
|
||||
"message": str, # REQUIRED - Notification body text
|
||||
|
||||
"notification_type": str, # OPTIONAL - 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info' (default: 'info')
|
||||
"priority": str, # OPTIONAL - 'low', 'normal', 'high', 'critical' (default: 'normal')
|
||||
"min_version": str | null, # OPTIONAL - Minimum version (inclusive), e.g., "0.17.0" (default: null)
|
||||
"max_version": str | null, # OPTIONAL - Maximum version (inclusive), e.g., "0.18.1" (default: null)
|
||||
"created_at": str, # OPTIONAL - ISO timestamp for tracking
|
||||
"expires_at": str | null, # OPTIONAL - ISO timestamp when notification expires (default: null)
|
||||
"condition": list[str], # OPTIONAL - List of condition check names, AND logic (default: [])
|
||||
"user_level": str, # OPTIONAL - 'all' or 'admin' (default: 'all')
|
||||
"action_url": str | null, # OPTIONAL - Internal navigation URL (e.g., "/settings#network-access")
|
||||
"action_text": str | null, # OPTIONAL - Text for action button (required if action_url is set)
|
||||
}
|
||||
|
||||
Condition Checks:
|
||||
Conditions are function names from CONDITION_CHECKS registry that evaluate
|
||||
whether a notification should be shown. All conditions must pass (AND logic).
|
||||
|
||||
Available conditions:
|
||||
- 'm3u_epg_network_insecure': M3U/EPG endpoint allows access from anywhere
|
||||
|
||||
To add new conditions:
|
||||
1. Define a function: check_your_condition(user) -> bool
|
||||
2. Add to CONDITION_CHECKS registry
|
||||
3. Reference in JSON: "condition": ["your_condition"]
|
||||
|
||||
Sync Behavior:
|
||||
- Runs on startup (see apps.py)
|
||||
- Runs when relevant settings change (see signals.py)
|
||||
- Adds new notifications if in version range and not expired
|
||||
- Updates existing notifications with latest data
|
||||
- Removes notifications that are:
|
||||
* No longer in JSON file
|
||||
* Out of current version range
|
||||
* Past expiration date
|
||||
- Sends websocket event to refresh frontend
|
||||
- Cache invalidated when triggering settings change
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from packaging import version
|
||||
|
||||
from version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path to developer notifications JSON file
|
||||
NOTIFICATIONS_FILE = Path(__file__).parent / 'fixtures' / 'developer_notifications.json'
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Condition Checks
|
||||
# ─────────────────────────────
|
||||
# Each condition function receives (user) and returns True if the notification should show
|
||||
|
||||
def check_network_access_is_default(user, endpoint: str = 'M3U_EPG') -> bool:
|
||||
"""
|
||||
Check if network access settings for a specific endpoint are insecure (allow all).
|
||||
|
||||
Args:
|
||||
user: The user object (unused but required for condition check signature)
|
||||
endpoint: The endpoint to check (e.g., 'M3U_EPG', 'XC_API')
|
||||
|
||||
Returns:
|
||||
True if the notification should show (insecure settings detected)
|
||||
"""
|
||||
from core.models import CoreSettings, NETWORK_ACCESS_KEY
|
||||
|
||||
try:
|
||||
network_settings = CoreSettings._get_group(NETWORK_ACCESS_KEY, {})
|
||||
|
||||
# Empty settings are secure (defaults to local network only)
|
||||
if not network_settings:
|
||||
return False
|
||||
|
||||
# Get the specific endpoint's allowed networks (stored as comma-separated string)
|
||||
allowed_networks_str = network_settings.get(endpoint, '')
|
||||
if not allowed_networks_str:
|
||||
return False
|
||||
|
||||
# Parse comma-separated network addresses
|
||||
allowed_networks = [net.strip() for net in allowed_networks_str.split(',')]
|
||||
|
||||
# Check if settings allow access from anywhere (insecure)
|
||||
if '0.0.0.0/0' in allowed_networks or '::/0' in allowed_networks:
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking network_access_is_default condition for {endpoint}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Registry of all available condition checks
|
||||
CONDITION_CHECKS: dict[str, Callable] = {
|
||||
'm3u_epg_network_insecure': lambda user: check_network_access_is_default(user, 'M3U_EPG'),
|
||||
# Add more conditions here as needed
|
||||
# 'transcode_not_configured': check_transcode_not_configured,
|
||||
# 'no_backup_configured': check_no_backup_configured,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Version Utilities
|
||||
# ─────────────────────────────
|
||||
|
||||
def parse_version(version_str: str | None) -> version.Version | None:
|
||||
"""Parse a version string, returning None if invalid or empty."""
|
||||
if not version_str:
|
||||
return None
|
||||
try:
|
||||
return version.parse(version_str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_version_in_range(
|
||||
current_version: str,
|
||||
min_version: str | None,
|
||||
max_version: str | None
|
||||
) -> bool:
|
||||
"""Check if current version is within the specified range."""
|
||||
current = parse_version(current_version)
|
||||
if not current:
|
||||
return True # If we can't parse version, show notification
|
||||
|
||||
min_ver = parse_version(min_version)
|
||||
max_ver = parse_version(max_version)
|
||||
|
||||
if min_ver and current < min_ver:
|
||||
return False
|
||||
if max_ver and current > max_ver:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Notification Evaluation
|
||||
# ─────────────────────────────
|
||||
|
||||
def evaluate_conditions(conditions: list[str] | str | None, user) -> bool:
|
||||
"""
|
||||
Evaluate notification conditions for a user.
|
||||
All conditions must pass (AND logic).
|
||||
"""
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
# Normalize to list
|
||||
if isinstance(conditions, str):
|
||||
conditions = [conditions]
|
||||
|
||||
for condition in conditions:
|
||||
if condition not in CONDITION_CHECKS:
|
||||
logger.warning(f"Unknown condition: {condition}")
|
||||
continue
|
||||
|
||||
try:
|
||||
if not CONDITION_CHECKS[condition](user):
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error evaluating condition {condition}: {e}")
|
||||
# On error, skip this condition (fail open)
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def should_show_notification(notification_data: dict, user) -> bool:
|
||||
"""
|
||||
Determine if a notification should be shown to a specific user.
|
||||
Checks version range, user level, and conditions.
|
||||
"""
|
||||
# Check version range
|
||||
if not is_version_in_range(
|
||||
__version__,
|
||||
notification_data.get('min_version'),
|
||||
notification_data.get('max_version')
|
||||
):
|
||||
return False
|
||||
|
||||
# Check user level
|
||||
user_level = notification_data.get('user_level', 'all')
|
||||
if user_level == 'admin' and not getattr(user, 'is_superuser', False):
|
||||
return False
|
||||
|
||||
# Check conditions
|
||||
conditions = notification_data.get('condition', [])
|
||||
if not evaluate_conditions(conditions, user):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# Sync Service
|
||||
# ─────────────────────────────
|
||||
|
||||
def load_developer_notifications() -> list[dict]:
|
||||
"""Load notifications from the JSON file."""
|
||||
if not NOTIFICATIONS_FILE.exists():
|
||||
logger.warning(f"Developer notifications file not found: {NOTIFICATIONS_FILE}")
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(NOTIFICATIONS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get('notifications', [])
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Error parsing developer notifications JSON: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading developer notifications: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def sync_developer_notifications() -> dict[str, int]:
|
||||
"""
|
||||
Sync developer notifications from JSON file to database.
|
||||
|
||||
- Adds new notifications that don't exist in the DB
|
||||
- Removes DB notifications that are no longer in the JSON file
|
||||
- Updates existing notifications if they've changed
|
||||
|
||||
Returns a dict with counts of added, updated, and removed notifications.
|
||||
"""
|
||||
from core.models import SystemNotification
|
||||
|
||||
results = {'added': 0, 'updated': 0, 'removed': 0, 'skipped': 0}
|
||||
|
||||
notifications = load_developer_notifications()
|
||||
json_notification_keys = set()
|
||||
notifications_to_remove = set() # Track notifications to remove (out of range or expired)
|
||||
|
||||
for notif_data in notifications:
|
||||
notification_id = notif_data.get('id')
|
||||
if not notification_id:
|
||||
logger.warning("Notification missing 'id' field, skipping")
|
||||
results['skipped'] += 1
|
||||
continue
|
||||
|
||||
json_notification_keys.add(notification_id)
|
||||
|
||||
# Check version constraints (only add if current version is in range)
|
||||
if not is_version_in_range(
|
||||
__version__,
|
||||
notif_data.get('min_version'),
|
||||
notif_data.get('max_version')
|
||||
):
|
||||
logger.debug(f"Notification {notification_id} not in version range, marking for removal")
|
||||
results['skipped'] += 1
|
||||
notifications_to_remove.add(notification_id)
|
||||
continue
|
||||
|
||||
# Parse expires_at if provided
|
||||
expires_at = None
|
||||
if notif_data.get('expires_at'):
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(
|
||||
notif_data['expires_at'].replace('Z', '+00:00')
|
||||
)
|
||||
# Skip if already expired and mark for removal
|
||||
if expires_at < timezone.now():
|
||||
logger.debug(f"Notification {notification_id} has expired, marking for removal")
|
||||
results['skipped'] += 1
|
||||
notifications_to_remove.add(notification_id)
|
||||
continue
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid expires_at for {notification_id}: {e}")
|
||||
|
||||
# Map notification_type from JSON to model choices
|
||||
type_mapping = {
|
||||
'version_update': SystemNotification.NotificationType.VERSION_UPDATE,
|
||||
'setting_recommendation': SystemNotification.NotificationType.SETTING_RECOMMENDATION,
|
||||
'announcement': SystemNotification.NotificationType.ANNOUNCEMENT,
|
||||
'warning': SystemNotification.NotificationType.WARNING,
|
||||
'info': SystemNotification.NotificationType.INFO,
|
||||
}
|
||||
notification_type = type_mapping.get(
|
||||
notif_data.get('notification_type', 'info'),
|
||||
SystemNotification.NotificationType.INFO
|
||||
)
|
||||
|
||||
# Map priority
|
||||
priority_mapping = {
|
||||
'low': SystemNotification.Priority.LOW,
|
||||
'normal': SystemNotification.Priority.NORMAL,
|
||||
'high': SystemNotification.Priority.HIGH,
|
||||
'critical': SystemNotification.Priority.CRITICAL,
|
||||
}
|
||||
priority = priority_mapping.get(
|
||||
notif_data.get('priority', 'normal'),
|
||||
SystemNotification.Priority.NORMAL
|
||||
)
|
||||
|
||||
# Prepare action_data
|
||||
action_data = {
|
||||
'action_url': notif_data.get('action_url'),
|
||||
'action_text': notif_data.get('action_text'),
|
||||
'condition': notif_data.get('condition', []),
|
||||
'min_version': notif_data.get('min_version'),
|
||||
'max_version': notif_data.get('max_version'),
|
||||
'user_level': notif_data.get('user_level', 'all'),
|
||||
}
|
||||
|
||||
# Determine if admin-only based on user_level
|
||||
admin_only = notif_data.get('user_level', 'all') == 'admin'
|
||||
|
||||
# Create or update the notification
|
||||
notification, created = SystemNotification.objects.update_or_create(
|
||||
notification_key=notification_id,
|
||||
defaults={
|
||||
'notification_type': notification_type,
|
||||
'priority': priority,
|
||||
'source': SystemNotification.Source.DEVELOPER,
|
||||
'title': notif_data.get('title', 'Notification'),
|
||||
'message': notif_data.get('message', ''),
|
||||
'action_data': action_data,
|
||||
'is_active': True,
|
||||
'admin_only': admin_only,
|
||||
'expires_at': expires_at,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
logger.info(f"Added developer notification: {notification_id}")
|
||||
results['added'] += 1
|
||||
else:
|
||||
logger.debug(f"Updated developer notification: {notification_id}")
|
||||
results['updated'] += 1
|
||||
|
||||
# Remove developer notifications that are:
|
||||
# - No longer in the JSON file, OR
|
||||
# - Out of version range for the current version, OR
|
||||
# - Expired
|
||||
removed_count, _ = SystemNotification.objects.filter(
|
||||
source=SystemNotification.Source.DEVELOPER
|
||||
).filter(
|
||||
models.Q(notification_key__in=notifications_to_remove) |
|
||||
~models.Q(notification_key__in=json_notification_keys)
|
||||
).delete()
|
||||
|
||||
if removed_count:
|
||||
logger.info(f"Removed {removed_count} obsolete/expired/out-of-range developer notification(s)")
|
||||
results['removed'] = removed_count
|
||||
|
||||
logger.info(
|
||||
f"Developer notification sync complete: "
|
||||
f"{results['added']} added, {results['updated']} updated, "
|
||||
f"{results['removed']} removed, {results['skipped']} skipped"
|
||||
)
|
||||
|
||||
# Send websocket notification to frontend to refresh notifications
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {
|
||||
'type': 'notifications_cleared',
|
||||
})
|
||||
logger.debug("Sent websocket notification for notifications refresh")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send websocket update: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_user_developer_notifications(user) -> list:
|
||||
"""
|
||||
Get all developer notifications that should be shown to a specific user.
|
||||
Evaluates conditions and user_level for each notification.
|
||||
"""
|
||||
from core.models import SystemNotification
|
||||
|
||||
# Get all active developer notifications
|
||||
notifications = SystemNotification.objects.filter(
|
||||
source=SystemNotification.Source.DEVELOPER,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Filter by admin_only based on user
|
||||
if not getattr(user, 'is_superuser', False):
|
||||
notifications = notifications.filter(admin_only=False)
|
||||
|
||||
# Filter by conditions
|
||||
result = []
|
||||
for notification in notifications:
|
||||
action_data = notification.action_data or {}
|
||||
|
||||
# Evaluate conditions
|
||||
conditions = action_data.get('condition', [])
|
||||
if evaluate_conditions(conditions, user):
|
||||
result.append(notification)
|
||||
|
||||
return result
|
||||
43
core/fixtures/developer_notifications.json
Normal file
43
core/fixtures/developer_notifications.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"_schema_documentation": {
|
||||
"description": "Developer notification definitions. Each notification is evaluated at sync time.",
|
||||
"fields": {
|
||||
"id": "[REQUIRED] Unique identifier (notification_key in database).",
|
||||
"notification_type": "[OPTIONAL] Type: 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info'. Default: 'info'",
|
||||
"priority": "[OPTIONAL] Priority level: 'low', 'normal', 'high', 'critical'. Default: 'normal'",
|
||||
"title": "[REQUIRED] Notification title/heading.",
|
||||
"message": "[REQUIRED] Detailed notification message body.",
|
||||
"min_version": "[OPTIONAL] Minimum version (inclusive). null = no minimum. Example: '0.17.0'",
|
||||
"max_version": "[OPTIONAL] Maximum version (inclusive). null = no maximum. Example: '0.18.1'",
|
||||
"created_at": "[OPTIONAL] ISO timestamp when notification was created. For tracking only.",
|
||||
"expires_at": "[OPTIONAL] ISO timestamp when notification expires. null = never expires. Example: '2026-12-31T23:59:59Z'",
|
||||
"condition": "[OPTIONAL] Array of condition check names that must all pass. Empty/null = always show. Example: ['m3u_epg_network_insecure']",
|
||||
"user_level": "[OPTIONAL] User level required: 'all' or 'admin'. Default: 'all'",
|
||||
"action_url": "[OPTIONAL] Internal URL to navigate to when action button clicked. Example: '/settings#network-access'",
|
||||
"action_text": "[OPTIONAL] Text for action button. Required if action_url is set. Example: 'Review Settings'"
|
||||
},
|
||||
"notes": [
|
||||
"Notifications are synced from this file to database on startup and when relevant settings change",
|
||||
"Out-of-range versions are automatically removed from database",
|
||||
"Expired notifications are automatically removed from database",
|
||||
"Conditions are evaluated per-user at display time (see CONDITION_CHECKS in developer_notifications.py)"
|
||||
]
|
||||
},
|
||||
"notifications": [
|
||||
{
|
||||
"id": "network_security_m3u_epg",
|
||||
"notification_type": "warning",
|
||||
"priority": "high",
|
||||
"title": "Network Access Security Warning",
|
||||
"message": "Your EPG/M3U output is accessible from any network. Consider restricting access to improve security.",
|
||||
"min_version": null,
|
||||
"max_version": null,
|
||||
"created_at": "2026-02-02T00:00:00Z",
|
||||
"expires_at": null,
|
||||
"condition": ["m3u_epg_network_insecure"],
|
||||
"user_level": "admin",
|
||||
"action_url": "/settings#network-access",
|
||||
"action_text": "Review Settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 5.2.9 on 2026-02-02 20:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_change_coresettings_value_to_jsonfield'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SystemNotification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('notification_key', models.CharField(db_index=True, max_length=255, unique=True)),
|
||||
('notification_type', models.CharField(choices=[('version_update', 'Version Update Available'), ('setting_recommendation', 'Recommended Setting Change'), ('announcement', 'System Announcement'), ('warning', 'Warning'), ('info', 'Information')], db_index=True, default='info', max_length=50)),
|
||||
('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('critical', 'Critical')], default='normal', max_length=20)),
|
||||
('source', models.CharField(choices=[('system', 'System Generated'), ('developer', 'Developer Notification')], db_index=True, default='system', max_length=20)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('message', models.TextField()),
|
||||
('action_data', models.JSONField(blank=True, default=dict)),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('admin_only', models.BooleanField(default=False)),
|
||||
('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-priority', '-created_at'],
|
||||
'indexes': [models.Index(fields=['is_active', '-created_at'], name='core_system_is_acti_afab03_idx'), models.Index(fields=['notification_type', 'is_active'], name='core_system_notific_2179e3_idx'), models.Index(fields=['source', 'is_active'], name='core_system_source_a35829_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NotificationDismissal',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('dismissed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('action_taken', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissed_notifications', to=settings.AUTH_USER_MODEL)),
|
||||
('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissals', to='core.systemnotification')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['user', 'notification'], name='core_notifi_user_id_93e02e_idx')],
|
||||
'unique_together': {('user', 'notification')},
|
||||
},
|
||||
),
|
||||
]
|
||||
150
core/models.py
150
core/models.py
|
|
@ -370,3 +370,153 @@ class SystemEvent(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}"
|
||||
|
||||
|
||||
class SystemNotification(models.Model):
|
||||
"""
|
||||
Stores system notifications that users can view and dismiss.
|
||||
Used for version updates, recommended settings, announcements, etc.
|
||||
"""
|
||||
class NotificationType(models.TextChoices):
|
||||
VERSION_UPDATE = 'version_update', 'Version Update Available'
|
||||
SETTING_RECOMMENDATION = 'setting_recommendation', 'Recommended Setting Change'
|
||||
ANNOUNCEMENT = 'announcement', 'System Announcement'
|
||||
WARNING = 'warning', 'Warning'
|
||||
INFO = 'info', 'Information'
|
||||
|
||||
class Priority(models.TextChoices):
|
||||
LOW = 'low', 'Low'
|
||||
NORMAL = 'normal', 'Normal'
|
||||
HIGH = 'high', 'High'
|
||||
CRITICAL = 'critical', 'Critical'
|
||||
|
||||
class Source(models.TextChoices):
|
||||
SYSTEM = 'system', 'System Generated'
|
||||
DEVELOPER = 'developer', 'Developer Notification'
|
||||
|
||||
# Unique identifier for the notification (e.g., 'version-0.19.0', 'setting-proxy-buffer')
|
||||
# This allows deduplication and targeted dismissals
|
||||
notification_key = models.CharField(max_length=255, unique=True, db_index=True)
|
||||
|
||||
notification_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=NotificationType.choices,
|
||||
default=NotificationType.INFO,
|
||||
db_index=True
|
||||
)
|
||||
priority = models.CharField(
|
||||
max_length=20,
|
||||
choices=Priority.choices,
|
||||
default=Priority.NORMAL
|
||||
)
|
||||
|
||||
# Source of the notification (system-generated vs developer-defined)
|
||||
source = models.CharField(
|
||||
max_length=20,
|
||||
choices=Source.choices,
|
||||
default=Source.SYSTEM,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
|
||||
# Optional action data (e.g., setting key/value for recommendations, release URL for versions)
|
||||
action_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Whether this notification is currently active
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
|
||||
# Admin-only notifications require admin privileges to view
|
||||
admin_only = models.BooleanField(default=False)
|
||||
|
||||
# Auto-expire after this date (null = never expires)
|
||||
expires_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-priority', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['is_active', '-created_at']),
|
||||
models.Index(fields=['notification_type', 'is_active']),
|
||||
models.Index(fields=['source', 'is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.notification_type}] {self.title}"
|
||||
|
||||
@classmethod
|
||||
def create_version_notification(cls, version, release_url=None, release_notes=None):
|
||||
"""Create or update a version update notification. Returns (notification, created) tuple."""
|
||||
key = f"version-{version}"
|
||||
notification, created = cls.objects.update_or_create(
|
||||
notification_key=key,
|
||||
defaults={
|
||||
'notification_type': cls.NotificationType.VERSION_UPDATE,
|
||||
'priority': cls.Priority.HIGH,
|
||||
'title': f'Version {version} Available',
|
||||
'message': f'A new version of Dispatcharr ({version}) is available.',
|
||||
'action_data': {
|
||||
'version': version,
|
||||
'release_url': release_url,
|
||||
'release_notes': release_notes,
|
||||
},
|
||||
'is_active': True,
|
||||
'admin_only': True,
|
||||
}
|
||||
)
|
||||
return notification, created
|
||||
|
||||
@classmethod
|
||||
def create_setting_recommendation(cls, setting_key, recommended_value, reason, current_value=None):
|
||||
"""Create a setting recommendation notification. Returns (notification, created) tuple."""
|
||||
key = f"setting-{setting_key}"
|
||||
notification, created = cls.objects.update_or_create(
|
||||
notification_key=key,
|
||||
defaults={
|
||||
'notification_type': cls.NotificationType.SETTING_RECOMMENDATION,
|
||||
'priority': cls.Priority.NORMAL,
|
||||
'title': f'Recommended Setting: {setting_key}',
|
||||
'message': reason,
|
||||
'action_data': {
|
||||
'setting_key': setting_key,
|
||||
'recommended_value': recommended_value,
|
||||
'current_value': current_value,
|
||||
},
|
||||
'is_active': True,
|
||||
'admin_only': True,
|
||||
}
|
||||
)
|
||||
return notification, created
|
||||
|
||||
|
||||
class NotificationDismissal(models.Model):
|
||||
"""
|
||||
Tracks which users have dismissed which notifications.
|
||||
Allows users to dismiss notifications once without seeing them again.
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='dismissed_notifications'
|
||||
)
|
||||
notification = models.ForeignKey(
|
||||
SystemNotification,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='dismissals'
|
||||
)
|
||||
dismissed_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Optional: track if user accepted/applied the recommendation
|
||||
action_taken = models.CharField(max_length=50, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['user', 'notification']
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'notification']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} dismissed {self.notification.notification_key}"
|
||||
|
|
|
|||
|
|
@ -64,7 +64,12 @@ class CoreSettingsSerializer(serializers.ModelSerializer):
|
|||
}
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
result = super().update(instance, validated_data)
|
||||
|
||||
# Note: Cache invalidation and notification sync is handled by post_save signal
|
||||
# in core/signals.py to ensure it happens even if settings are updated elsewhere
|
||||
|
||||
return result
|
||||
|
||||
class ProxySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for proxy settings stored as JSON in CoreSettings"""
|
||||
|
|
@ -98,3 +103,45 @@ class ProxySettingsSerializer(serializers.Serializer):
|
|||
if value < 0 or value > 60:
|
||||
raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds")
|
||||
return value
|
||||
|
||||
|
||||
class SystemNotificationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for system notifications."""
|
||||
is_dismissed = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
from .models import SystemNotification
|
||||
model = SystemNotification
|
||||
fields = [
|
||||
'id',
|
||||
'notification_key',
|
||||
'notification_type',
|
||||
'priority',
|
||||
'title',
|
||||
'message',
|
||||
'action_data',
|
||||
'is_active',
|
||||
'admin_only',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
'is_dismissed',
|
||||
'source',
|
||||
]
|
||||
read_only_fields = ['created_at']
|
||||
|
||||
def get_is_dismissed(self, obj):
|
||||
"""Check if the current user has dismissed this notification."""
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
return obj.dismissals.filter(user=request.user).exists()
|
||||
return False
|
||||
|
||||
|
||||
class NotificationDismissalSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notification dismissals."""
|
||||
|
||||
class Meta:
|
||||
from .models import NotificationDismissal
|
||||
model = NotificationDismissal
|
||||
fields = ['id', 'notification', 'dismissed_at', 'action_taken']
|
||||
read_only_fields = ['dismissed_at']
|
||||
|
|
|
|||
|
|
@ -1,9 +1,39 @@
|
|||
from django.db.models.signals import pre_delete
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import StreamProfile
|
||||
from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY
|
||||
|
||||
@receiver(pre_delete, sender=StreamProfile)
|
||||
def prevent_deletion_if_locked(sender, instance, **kwargs):
|
||||
if instance.locked:
|
||||
raise ValidationError("This profile is locked and cannot be deleted.")
|
||||
|
||||
@receiver(post_save, sender=CoreSettings)
|
||||
def handle_network_access_update(sender, instance, **kwargs):
|
||||
"""Invalidate cache and sync notifications when network access settings change."""
|
||||
if instance.key == NETWORK_ACCESS_KEY:
|
||||
from django.core.cache import cache
|
||||
from core.developer_notifications import sync_developer_notifications
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Invalidate all notification condition caches
|
||||
try:
|
||||
cache.delete_pattern('dev_notif_condition_*')
|
||||
logger.info("Invalidated notification condition cache due to network access settings update")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete cache pattern: {e}")
|
||||
# Fallback: try to clear entire cache (if delete_pattern not supported)
|
||||
try:
|
||||
cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-sync developer notifications to re-evaluate conditions
|
||||
# (websocket notification is sent by sync_developer_notifications)
|
||||
try:
|
||||
sync_developer_notifications()
|
||||
logger.info("Re-synced developer notifications after network access settings update")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync developer notifications: {e}")
|
||||
|
|
|
|||
224
core/tasks.py
224
core/tasks.py
|
|
@ -765,3 +765,227 @@ def cleanup_vod_persistent_connections():
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during VOD persistent connection cleanup: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_for_version_update():
|
||||
"""
|
||||
Check for new Dispatcharr versions on GitHub and create a notification if available.
|
||||
This task should be run periodically (e.g., daily) via Celery Beat.
|
||||
|
||||
For dev builds (identified by __timestamp__), checks for stable releases only.
|
||||
For production builds, checks for stable releases.
|
||||
|
||||
Note: Dev builds are container images from the dev branch and don't have GitHub releases.
|
||||
This checks if a stable release is available so dev users know when to upgrade.
|
||||
"""
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
from packaging import version as pkg_version
|
||||
from version import __version__, __timestamp__
|
||||
from core.models import SystemNotification
|
||||
from core.utils import send_websocket_notification
|
||||
|
||||
try:
|
||||
is_dev_build = __timestamp__ is not None
|
||||
DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'}
|
||||
if is_dev_build:
|
||||
# Check Docker Hub for newer dev builds
|
||||
docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev"
|
||||
|
||||
response = requests.get(docker_hub_url, headers=DISPATCHARR_HEADERS, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Failed to check Docker Hub for dev updates: HTTP {response.status_code}")
|
||||
return
|
||||
|
||||
dev_tag_data = response.json()
|
||||
docker_last_updated = dev_tag_data.get("last_updated")
|
||||
|
||||
if not docker_last_updated:
|
||||
logger.warning("No last_updated timestamp found in Docker Hub response")
|
||||
return
|
||||
|
||||
# Parse timestamps for comparison
|
||||
local_dt = datetime.strptime(__timestamp__, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
|
||||
docker_dt = datetime.fromisoformat(docker_last_updated.replace('Z', '+00:00'))
|
||||
|
||||
# Calculate difference in minutes
|
||||
diff_minutes = (docker_dt - local_dt).total_seconds() / 60
|
||||
|
||||
# Threshold to account for build/push time differences
|
||||
THRESHOLD_MINUTES = 10
|
||||
|
||||
if diff_minutes > THRESHOLD_MINUTES:
|
||||
logger.info(f"New dev build available on Docker Hub (updated {int(diff_minutes)} minutes after current build)")
|
||||
|
||||
# Delete any old version update notifications (both dev and stable, in case user switched)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).delete()[0]
|
||||
if deleted_count > 0:
|
||||
logger.debug(f"Deleted {deleted_count} old dev build notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
|
||||
# Create notification for new dev build
|
||||
notification, created = SystemNotification.objects.get_or_create(
|
||||
notification_key=f'version-dev-{docker_last_updated}',
|
||||
defaults={
|
||||
'notification_type': 'version_update',
|
||||
'title': 'New Dev Build Available',
|
||||
'message': f'A newer development build is available on Docker Hub (v{__version__}-dev)',
|
||||
'priority': 'medium',
|
||||
'action_data': {
|
||||
'current_version': __version__,
|
||||
'current_timestamp': __timestamp__,
|
||||
'docker_updated': docker_last_updated,
|
||||
'update_url': 'https://hub.docker.com/r/dispatcharr/dispatcharr/tags'
|
||||
},
|
||||
'is_active': True,
|
||||
'admin_only': True,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
# Only send WebSocket for newly created notifications
|
||||
send_websocket_notification(notification)
|
||||
logger.info(f"New dev build notification created and sent via WebSocket")
|
||||
else:
|
||||
logger.debug(f"Dev build is up to date (Docker Hub image is {abs(int(diff_minutes))} minutes {'newer' if diff_minutes > 0 else 'older'})")
|
||||
|
||||
# Delete all version update notifications when up to date (both dev and stable)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).delete()[0]
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Deleted {deleted_count} outdated dev build notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Production build - check GitHub for stable releases
|
||||
github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest"
|
||||
headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS}
|
||||
response = requests.get(
|
||||
github_api_url,
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Failed to check for updates: HTTP {response.status_code}")
|
||||
return
|
||||
|
||||
release_data = response.json()
|
||||
latest_version = release_data.get("tag_name", "").lstrip("v")
|
||||
release_url = release_data.get("html_url", "")
|
||||
|
||||
if not latest_version:
|
||||
logger.warning("No version tag found in GitHub release")
|
||||
return
|
||||
|
||||
# Compare versions
|
||||
current = pkg_version.parse(__version__)
|
||||
latest = pkg_version.parse(latest_version)
|
||||
if latest > current:
|
||||
logger.info(f"New stable version available: {latest_version} (current: {__version__})")
|
||||
|
||||
# Delete any old version update notifications (superseded by this one)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).exclude(
|
||||
notification_key=f"version-{latest_version}"
|
||||
).delete()[0]
|
||||
if deleted_count > 0:
|
||||
logger.debug(f"Deleted {deleted_count} old version notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
|
||||
# Create or update the notification for the new version
|
||||
notification, created = SystemNotification.create_version_notification(
|
||||
version=latest_version,
|
||||
release_url=release_url,
|
||||
)
|
||||
|
||||
if created:
|
||||
# Only send WebSocket for newly created notifications
|
||||
send_websocket_notification(notification)
|
||||
logger.info(f"New version notification created and sent via WebSocket")
|
||||
else:
|
||||
logger.debug(f"Dispatcharr is up to date (v{__version__})")
|
||||
|
||||
# Delete ALL version update notifications when up to date (no longer needed)
|
||||
deleted_count = SystemNotification.objects.filter(
|
||||
notification_type='version_update'
|
||||
).delete()[0]
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Deleted {deleted_count} outdated version notification(s)")
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'success': True,
|
||||
'type': 'notifications_cleared',
|
||||
'count': deleted_count
|
||||
}
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Network error checking for updates: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for version updates: {e}")
|
||||
|
||||
|
||||
def create_setting_recommendation(setting_key, recommended_value, reason, current_value=None):
|
||||
"""
|
||||
Create a setting recommendation notification.
|
||||
This is a helper function that can be called from anywhere in the codebase.
|
||||
|
||||
Args:
|
||||
setting_key: The setting key (e.g., 'proxy_settings.buffering_timeout')
|
||||
recommended_value: The recommended value for the setting
|
||||
reason: Why this setting is recommended
|
||||
current_value: The current value (optional)
|
||||
|
||||
Returns:
|
||||
The created SystemNotification instance
|
||||
"""
|
||||
from core.models import SystemNotification
|
||||
from core.utils import send_websocket_notification
|
||||
|
||||
notification, created = SystemNotification.create_setting_recommendation(
|
||||
setting_key=setting_key,
|
||||
recommended_value=recommended_value,
|
||||
reason=reason,
|
||||
current_value=current_value
|
||||
)
|
||||
|
||||
# Only send via WebSocket for newly created notifications
|
||||
if created:
|
||||
send_websocket_notification(notification)
|
||||
|
||||
return notification
|
||||
|
||||
|
|
|
|||
|
|
@ -437,3 +437,76 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
|
|||
except Exception as e:
|
||||
# Don't let event logging break the main application
|
||||
logger.error(f"Failed to log system event {event_type}: {e}")
|
||||
|
||||
|
||||
def send_websocket_notification(notification):
|
||||
"""
|
||||
Send a system notification to all connected WebSocket clients.
|
||||
|
||||
Args:
|
||||
notification: A SystemNotification model instance or dict with notification data
|
||||
|
||||
Example:
|
||||
from core.models import SystemNotification
|
||||
notification = SystemNotification.create_version_notification('0.19.0', 'https://...')
|
||||
send_websocket_notification(notification)
|
||||
"""
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
|
||||
# Convert model instance to dict if needed
|
||||
if hasattr(notification, 'id'):
|
||||
notification_data = {
|
||||
'id': notification.id,
|
||||
'notification_key': notification.notification_key,
|
||||
'notification_type': notification.notification_type,
|
||||
'priority': notification.priority,
|
||||
'title': notification.title,
|
||||
'message': notification.message,
|
||||
'action_data': notification.action_data,
|
||||
'is_active': notification.is_active,
|
||||
'admin_only': notification.admin_only,
|
||||
'created_at': notification.created_at.isoformat() if notification.created_at else None,
|
||||
}
|
||||
else:
|
||||
notification_data = notification
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
'type': 'system_notification',
|
||||
'notification': notification_data,
|
||||
}
|
||||
}
|
||||
)
|
||||
logger.debug(f"Sent WebSocket notification: {notification_data.get('title', 'Unknown')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send WebSocket notification: {e}")
|
||||
|
||||
|
||||
def send_notification_dismissed(notification_key):
|
||||
"""
|
||||
Notify all connected clients that a notification was dismissed.
|
||||
Useful for syncing dismissal state across multiple browser tabs/sessions.
|
||||
|
||||
Args:
|
||||
notification_key: The unique key of the dismissed notification
|
||||
"""
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
'type': 'notification_dismissed',
|
||||
'notification_key': notification_key,
|
||||
}
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification dismissed event: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -236,6 +236,11 @@ CELERY_BEAT_SCHEDULE = {
|
|||
"task": "apps.channels.tasks.maintain_recurring_recordings",
|
||||
"schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead
|
||||
},
|
||||
# Check for version updates daily
|
||||
"check-version-updates": {
|
||||
"task": "core.tasks.check_for_version_update",
|
||||
"schedule": 86400.0, # Once every 24 hours
|
||||
},
|
||||
}
|
||||
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
|
|
|||
|
|
@ -845,6 +845,59 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'system_notification': {
|
||||
// Handle real-time system notifications (version updates, setting recommendations, etc.)
|
||||
const notificationData = parsedEvent.data.notification;
|
||||
if (notificationData) {
|
||||
// Import and update the notifications store
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore
|
||||
.getState()
|
||||
.addNotification(notificationData);
|
||||
|
||||
// Show a toast notification for high priority items
|
||||
if (
|
||||
notificationData.priority === 'high' ||
|
||||
notificationData.priority === 'critical'
|
||||
) {
|
||||
const color =
|
||||
notificationData.notification_type === 'version_update'
|
||||
? 'green'
|
||||
: notificationData.notification_type === 'warning'
|
||||
? 'orange'
|
||||
: 'blue';
|
||||
|
||||
notifications.show({
|
||||
title: notificationData.title,
|
||||
message: notificationData.message,
|
||||
color,
|
||||
autoClose: 10000,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notification_dismissed': {
|
||||
// Handle notification dismissed from another session
|
||||
const { notification_key } = parsedEvent.data;
|
||||
if (notification_key) {
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore
|
||||
.getState()
|
||||
.dismissNotification(notification_key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notifications_cleared': {
|
||||
// Handle bulk notification clearing (e.g., when version is updated)
|
||||
API.getNotifications();
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(
|
||||
`Unknown websocket event type: ${parsedEvent.data?.type}`
|
||||
|
|
|
|||
|
|
@ -2890,4 +2890,107 @@ export default class API {
|
|||
errorNotification('Failed to retrieve system events', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────
|
||||
// System Notifications
|
||||
// ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all active notifications for the current user
|
||||
* @param {boolean} includeDismissed - Whether to include already dismissed notifications
|
||||
*/
|
||||
static async getNotifications(includeDismissed = false) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (includeDismissed) {
|
||||
params.append('include_dismissed', 'true');
|
||||
}
|
||||
const response = await request(
|
||||
`${host}/api/core/notifications/?${params.toString()}`
|
||||
);
|
||||
|
||||
// Update the store with fetched notifications
|
||||
const { default: useNotificationsStore } = await import(
|
||||
'./store/notifications'
|
||||
);
|
||||
useNotificationsStore.getState().setNotifications(response.notifications);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve notifications', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get unread notification count
|
||||
static async getNotificationCount() {
|
||||
try {
|
||||
const response = await request(`${host}/api/core/notifications/count/`);
|
||||
|
||||
// Update the store with the count
|
||||
const { default: useNotificationsStore } = await import(
|
||||
'./store/notifications'
|
||||
);
|
||||
useNotificationsStore.getState().setUnreadCount(response.unread_count);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
// Silent fail for count - not critical
|
||||
console.error('Failed to get notification count:', e);
|
||||
return { unread_count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a specific notification
|
||||
* @param {number} notificationId - The notification ID to dismiss
|
||||
* @param {string} actionTaken - Optional action taken (e.g., 'applied', 'ignored')
|
||||
*/
|
||||
static async dismissNotification(notificationId, actionTaken = null) {
|
||||
try {
|
||||
const body = {};
|
||||
if (actionTaken) {
|
||||
body.action_taken = actionTaken;
|
||||
}
|
||||
|
||||
const response = await request(
|
||||
`${host}/api/core/notifications/${notificationId}/dismiss/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
);
|
||||
|
||||
// Update the store
|
||||
const { default: useNotificationsStore } = await import(
|
||||
'./store/notifications'
|
||||
);
|
||||
useNotificationsStore.getState().dismissNotification(response.notification_key);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to dismiss notification', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss all notifications
|
||||
static async dismissAllNotifications() {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/core/notifications/dismiss-all/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
|
||||
// Update the store
|
||||
const { default: useNotificationsStore } = await import(
|
||||
'./store/notifications'
|
||||
);
|
||||
useNotificationsStore.getState().dismissAllNotifications();
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to dismiss all notifications', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
429
frontend/src/components/NotificationCenter.jsx
Normal file
429
frontend/src/components/NotificationCenter.jsx
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Indicator,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
Megaphone,
|
||||
X,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import useNotificationsStore from '../store/notifications';
|
||||
import API from '../api';
|
||||
|
||||
// Get icon for notification type
|
||||
const getNotificationIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'version_update':
|
||||
return <Download size={16} />;
|
||||
case 'setting_recommendation':
|
||||
return <Settings size={16} />;
|
||||
case 'announcement':
|
||||
return <Megaphone size={16} />;
|
||||
case 'warning':
|
||||
return <AlertTriangle size={16} />;
|
||||
case 'info':
|
||||
default:
|
||||
return <Info size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get color for notification priority
|
||||
const getPriorityColor = (priority) => {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return 'red';
|
||||
case 'high':
|
||||
return 'orange';
|
||||
case 'normal':
|
||||
return 'blue';
|
||||
case 'low':
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
// Get color for notification type
|
||||
const getTypeColor = (type) => {
|
||||
switch (type) {
|
||||
case 'version_update':
|
||||
return 'green';
|
||||
case 'setting_recommendation':
|
||||
return 'blue';
|
||||
case 'announcement':
|
||||
return 'violet';
|
||||
case 'warning':
|
||||
return 'orange';
|
||||
case 'info':
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
// Individual notification item component
|
||||
const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => {
|
||||
const theme = useMantineTheme();
|
||||
const navigate = useNavigate();
|
||||
const typeColor = getTypeColor(notification.notification_type);
|
||||
const priorityColor = getPriorityColor(notification.priority);
|
||||
const isDismissed = notification.is_dismissed;
|
||||
|
||||
const handleDismiss = (e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(notification.id, 'dismissed');
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
// Handle action_url from action_data
|
||||
const actionUrl = notification.action_data?.action_url;
|
||||
const releaseUrl = notification.action_data?.release_url;
|
||||
|
||||
if (actionUrl) {
|
||||
// Internal navigation
|
||||
onClose(); // Close the popover
|
||||
navigate(actionUrl);
|
||||
} else if (releaseUrl) {
|
||||
// External link
|
||||
window.open(releaseUrl, '_blank');
|
||||
}
|
||||
|
||||
if (onAction) {
|
||||
onAction(notification);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
borderLeft: `3px solid ${theme.colors[priorityColor][5]}`,
|
||||
backgroundColor:
|
||||
notification.priority === 'critical'
|
||||
? theme.colors.red[9] + '10'
|
||||
: undefined,
|
||||
opacity: isDismissed ? 0.6 : 1,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Dismiss button for non-setting notifications (only if not already dismissed) */}
|
||||
{notification.notification_type !== 'setting_recommendation' &&
|
||||
!isDismissed && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
style={{ position: 'absolute', top: 8, right: 8 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||
<ThemeIcon color={typeColor} variant="light" size="md" radius="xl">
|
||||
{getNotificationIcon(notification.notification_type)}
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group gap="xs" mb={4}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
{isDismissed && (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
Dismissed
|
||||
</Badge>
|
||||
)}
|
||||
{notification.priority === 'high' ||
|
||||
notification.priority === 'critical' ? (
|
||||
<Badge size="xs" color={priorityColor} variant="filled">
|
||||
{notification.priority}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" lineClamp={5}>
|
||||
{notification.message}
|
||||
</Text>
|
||||
|
||||
{/* Action buttons for specific notification types */}
|
||||
{notification.notification_type === 'version_update' &&
|
||||
notification.action_data?.release_url && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
mt="xs"
|
||||
leftSection={<ExternalLink size={12} />}
|
||||
onClick={handleAction}
|
||||
>
|
||||
View Release
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Generic action button for notifications with action_url/action_text */}
|
||||
{notification.action_data?.action_url &&
|
||||
notification.action_data?.action_text && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={typeColor}
|
||||
mt="xs"
|
||||
rightSection={<ArrowRight size={12} />}
|
||||
onClick={handleAction}
|
||||
>
|
||||
{notification.action_data.action_text}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{notification.notification_type === 'setting_recommendation' &&
|
||||
!notification.action_data?.action_url && (
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => {
|
||||
onDismiss(notification.id, 'applied');
|
||||
// Navigate to settings or apply the setting
|
||||
if (onAction) onAction(notification);
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
Ignore
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed" mt="xs" ta="right">
|
||||
{new Date(notification.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Main notification center component with bell icon and popover
|
||||
const NotificationCenter = ({ onSettingAction }) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [showDismissed, setShowDismissed] = useState(false);
|
||||
|
||||
const notifications = useNotificationsStore((s) => s.notifications);
|
||||
const unreadCount = useNotificationsStore((s) => s.unreadCount);
|
||||
const getUnreadNotifications = useNotificationsStore(
|
||||
(s) => s.getUnreadNotifications
|
||||
);
|
||||
|
||||
// Fetch notifications on mount and periodically
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
await API.getNotifications(showDismissed);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notifications:', error);
|
||||
}
|
||||
}, [showDismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
|
||||
// Refresh notifications every 5 minutes
|
||||
const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const handleDismiss = async (notificationId, actionTaken = null) => {
|
||||
try {
|
||||
await API.dismissNotification(notificationId, actionTaken);
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissAll = async () => {
|
||||
try {
|
||||
await API.dismissAllNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss all notifications:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (notification) => {
|
||||
if (
|
||||
notification.notification_type === 'setting_recommendation' &&
|
||||
onSettingAction
|
||||
) {
|
||||
onSettingAction(notification);
|
||||
}
|
||||
};
|
||||
|
||||
const unreadNotifications = getUnreadNotifications();
|
||||
const displayedNotifications = showDismissed
|
||||
? notifications
|
||||
: unreadNotifications;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={380}
|
||||
position="bottom-end"
|
||||
shadow="lg"
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<Indicator
|
||||
color="red"
|
||||
size={16}
|
||||
label={unreadCount > 9 ? '9+' : unreadCount}
|
||||
disabled={unreadCount === 0}
|
||||
offset={4}
|
||||
processing={unreadCount > 0}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={20} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown p={0}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" p="sm" pb="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Notifications
|
||||
</Text>
|
||||
{unreadCount > 0 && (
|
||||
<Badge size="sm" color="blue" variant="light">
|
||||
{unreadCount} new
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Tooltip
|
||||
label={showDismissed ? 'Hide dismissed' : 'Show dismissed'}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => setShowDismissed((prev) => !prev)}
|
||||
>
|
||||
{showDismissed ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{unreadCount > 0 && (
|
||||
<Tooltip label="Mark all as read">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={handleDismissAll}
|
||||
>
|
||||
<CheckCheck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Notification list */}
|
||||
<ScrollArea.Autosize mah={400} type="auto" offsetScrollbars>
|
||||
{displayedNotifications.length === 0 ? (
|
||||
<Box p="lg" ta="center">
|
||||
<ThemeIcon
|
||||
color="gray"
|
||||
variant="light"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
mb="sm"
|
||||
>
|
||||
<Check size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{showDismissed
|
||||
? 'No dismissed notifications'
|
||||
: 'All caught up!'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{showDismissed
|
||||
? 'Dismissed notifications appear here'
|
||||
: 'No new notifications'}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap="xs" p="xs">
|
||||
{displayedNotifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onDismiss={handleDismiss}
|
||||
onAction={handleAction}
|
||||
onClose={() => setOpened(false)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
{/* Footer with info text */}
|
||||
{!showDismissed &&
|
||||
notifications.length > unreadNotifications.length && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box p="xs" ta="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{notifications.length - unreadNotifications.length} dismissed
|
||||
notification
|
||||
{notifications.length - unreadNotifications.length !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
|
|
@ -36,6 +36,7 @@ import useSettingsStore from '../store/settings';
|
|||
import useAuthStore from '../store/auth';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
import UserForm from './forms/User';
|
||||
import NotificationCenter from './NotificationCenter';
|
||||
|
||||
const NavLink = ({ item, isActive, collapsed }) => {
|
||||
return (
|
||||
|
|
@ -232,7 +233,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
}}
|
||||
>
|
||||
{isAuthenticated && (
|
||||
<Group>
|
||||
<Stack gap="sm">
|
||||
{!collapsed && (
|
||||
<TextInput
|
||||
label="Public IP"
|
||||
|
|
@ -262,34 +263,54 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
/>
|
||||
)}
|
||||
|
||||
<Avatar src="" radius="xl" />
|
||||
{!collapsed && authUser && (
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
gap="xs"
|
||||
style={{ justifyContent: 'space-between', width: '100%' }}
|
||||
>
|
||||
<UnstyledButton onClick={() => setUserFormOpen(true)}>
|
||||
{authUser.first_name || authUser.username}
|
||||
</UnstyledButton>
|
||||
|
||||
<Group gap="xs">
|
||||
<Avatar src="" radius="xl" />
|
||||
<UnstyledButton onClick={() => setUserFormOpen(true)}>
|
||||
{authUser.first_name || authUser.username}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<ActionIcon variant="transparent" color="white" size="sm">
|
||||
<LogOut onClick={logout} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{collapsed && (
|
||||
<Group gap="xs">
|
||||
<Avatar src="" radius="xl" />
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Version is always shown when sidebar is expanded, regardless of auth status */}
|
||||
{/* Version and Notification */}
|
||||
{!collapsed && (
|
||||
<Text size="xs" style={{ padding: '0 16px 16px' }} c="dimmed">
|
||||
v{appVersion?.version || '0.0.0'}
|
||||
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
|
||||
</Text>
|
||||
<Group
|
||||
gap="xs"
|
||||
style={{ padding: '0 16px 16px', justifyContent: 'space-between' }}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{appVersion?.version || '0.0.0'}
|
||||
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
|
||||
</Text>
|
||||
{isAuthenticated && <NotificationCenter />}
|
||||
</Group>
|
||||
)}
|
||||
{collapsed && isAuthenticated && (
|
||||
<Box
|
||||
style={{
|
||||
padding: '0 16px 16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<NotificationCenter />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { Suspense, useState } from 'react';
|
||||
import React, { Suspense, useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionControl,
|
||||
|
|
@ -7,48 +8,64 @@ import {
|
|||
Box,
|
||||
Center,
|
||||
Text,
|
||||
Loader
|
||||
Loader,
|
||||
} from '@mantine/core';
|
||||
const UserAgentsTable = React.lazy(() =>
|
||||
import('../components/tables/UserAgentsTable.jsx'));
|
||||
const StreamProfilesTable = React.lazy(() =>
|
||||
import('../components/tables/StreamProfilesTable.jsx'));
|
||||
const BackupManager = React.lazy(() =>
|
||||
import('../components/backups/BackupManager.jsx'));
|
||||
const UserAgentsTable = React.lazy(
|
||||
() => import('../components/tables/UserAgentsTable.jsx')
|
||||
);
|
||||
const StreamProfilesTable = React.lazy(
|
||||
() => import('../components/tables/StreamProfilesTable.jsx')
|
||||
);
|
||||
const BackupManager = React.lazy(
|
||||
() => import('../components/backups/BackupManager.jsx')
|
||||
);
|
||||
import useAuthStore from '../store/auth';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
|
||||
import ErrorBoundary from '../components/ErrorBoundary.jsx';
|
||||
const NetworkAccessForm = React.lazy(() =>
|
||||
import('../components/forms/settings/NetworkAccessForm.jsx'));
|
||||
const ProxySettingsForm = React.lazy(() =>
|
||||
import('../components/forms/settings/ProxySettingsForm.jsx'));
|
||||
const StreamSettingsForm = React.lazy(() =>
|
||||
import('../components/forms/settings/StreamSettingsForm.jsx'));
|
||||
const DvrSettingsForm = React.lazy(() =>
|
||||
import('../components/forms/settings/DvrSettingsForm.jsx'));
|
||||
const SystemSettingsForm = React.lazy(() =>
|
||||
import('../components/forms/settings/SystemSettingsForm.jsx'));
|
||||
const NetworkAccessForm = React.lazy(
|
||||
() => import('../components/forms/settings/NetworkAccessForm.jsx')
|
||||
);
|
||||
const ProxySettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/ProxySettingsForm.jsx')
|
||||
);
|
||||
const StreamSettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/StreamSettingsForm.jsx')
|
||||
);
|
||||
const DvrSettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/DvrSettingsForm.jsx')
|
||||
);
|
||||
const SystemSettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/SystemSettingsForm.jsx')
|
||||
);
|
||||
|
||||
const SettingsPage = () => {
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const location = useLocation();
|
||||
|
||||
const [accordianValue, setAccordianValue] = useState(null);
|
||||
const [accordianValue, setAccordianValue] = useState('ui-settings');
|
||||
|
||||
// Handle hash navigation to open specific accordion
|
||||
useEffect(() => {
|
||||
const hash = location.hash.replace('#', '');
|
||||
if (hash) {
|
||||
setAccordianValue(hash);
|
||||
}
|
||||
}, [location.hash]);
|
||||
|
||||
return (
|
||||
<Center p={10}>
|
||||
<Box w={'100%'} maw={800}>
|
||||
<Accordion
|
||||
variant="separated"
|
||||
defaultValue="ui-settings"
|
||||
value={accordianValue}
|
||||
onChange={setAccordianValue}
|
||||
miw={400}
|
||||
>
|
||||
<AccordionItem value="ui-settings">
|
||||
<AccordionControl>UI Settings</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<UiSettingsForm
|
||||
active={accordianValue === 'ui-settings'} />
|
||||
<UiSettingsForm active={accordianValue === 'ui-settings'} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
|
|
@ -60,7 +77,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<DvrSettingsForm
|
||||
active={accordianValue === 'dvr-settings'} />
|
||||
active={accordianValue === 'dvr-settings'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -72,7 +90,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<StreamSettingsForm
|
||||
active={accordianValue === 'stream-settings'} />
|
||||
active={accordianValue === 'stream-settings'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -84,7 +103,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<SystemSettingsForm
|
||||
active={accordianValue === 'system-settings'} />
|
||||
active={accordianValue === 'system-settings'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -96,7 +116,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<UserAgentsTable
|
||||
active={accordianValue === 'user-agents'} />
|
||||
active={accordianValue === 'user-agents'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -108,7 +129,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<StreamProfilesTable
|
||||
active={accordianValue === 'stream-profiles'} />
|
||||
active={accordianValue === 'stream-profiles'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -127,7 +149,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<NetworkAccessForm
|
||||
active={accordianValue === 'network-access'} />
|
||||
active={accordianValue === 'network-access'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
@ -141,7 +164,8 @@ const SettingsPage = () => {
|
|||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<ProxySettingsForm
|
||||
active={accordianValue === 'proxy-settings'} />
|
||||
active={accordianValue === 'proxy-settings'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
|
|
|
|||
111
frontend/src/store/notifications.jsx
Normal file
111
frontend/src/store/notifications.jsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
// Store for managing system notifications (version updates, recommended settings, announcements)
|
||||
const useNotificationsStore = create((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
|
||||
// Set notifications directly (used by API layer)
|
||||
setNotifications: (notifications) => {
|
||||
const unreadCount = notifications.filter((n) => !n.is_dismissed).length;
|
||||
set({
|
||||
notifications,
|
||||
unreadCount,
|
||||
lastFetched: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
// Add a new notification (e.g., from WebSocket)
|
||||
addNotification: (notification) => {
|
||||
set((state) => {
|
||||
const exists = state.notifications.some(
|
||||
(n) => n.notification_key === notification.notification_key
|
||||
);
|
||||
if (exists) {
|
||||
// Update existing notification
|
||||
return {
|
||||
notifications: state.notifications.map((n) =>
|
||||
n.notification_key === notification.notification_key
|
||||
? { ...n, ...notification }
|
||||
: n
|
||||
),
|
||||
};
|
||||
}
|
||||
// Add new notification
|
||||
const newNotifications = [notification, ...state.notifications];
|
||||
return {
|
||||
notifications: newNotifications,
|
||||
unreadCount: newNotifications.filter((n) => !n.is_dismissed).length,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Mark a notification as dismissed locally
|
||||
dismissNotification: (notificationKey) => {
|
||||
set((state) => {
|
||||
const notifications = state.notifications.map((n) =>
|
||||
n.notification_key === notificationKey
|
||||
? { ...n, is_dismissed: true }
|
||||
: n
|
||||
);
|
||||
return {
|
||||
notifications,
|
||||
unreadCount: notifications.filter((n) => !n.is_dismissed).length,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Mark all notifications as dismissed locally
|
||||
dismissAllNotifications: () => {
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) => ({
|
||||
...n,
|
||||
is_dismissed: true,
|
||||
})),
|
||||
unreadCount: 0,
|
||||
}));
|
||||
},
|
||||
|
||||
// Remove a notification from the store
|
||||
removeNotification: (notificationKey) => {
|
||||
set((state) => {
|
||||
const notifications = state.notifications.filter(
|
||||
(n) => n.notification_key !== notificationKey
|
||||
);
|
||||
return {
|
||||
notifications,
|
||||
unreadCount: notifications.filter((n) => !n.is_dismissed).length,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Update unread count
|
||||
setUnreadCount: (count) => {
|
||||
set({ unreadCount: count });
|
||||
},
|
||||
|
||||
// Set loading state
|
||||
setLoading: (isLoading) => {
|
||||
set({ isLoading });
|
||||
},
|
||||
|
||||
// Set error state
|
||||
setError: (error) => {
|
||||
set({ error });
|
||||
},
|
||||
|
||||
// Get notifications by type
|
||||
getNotificationsByType: (type) => {
|
||||
return get().notifications.filter((n) => n.notification_type === type);
|
||||
},
|
||||
|
||||
// Get unread notifications only
|
||||
getUnreadNotifications: () => {
|
||||
return get().notifications.filter((n) => !n.is_dismissed);
|
||||
},
|
||||
}));
|
||||
|
||||
export default useNotificationsStore;
|
||||
|
|
@ -34,6 +34,7 @@ dependencies = [
|
|||
"django-filter",
|
||||
"django-celery-beat",
|
||||
"lxml==6.0.2",
|
||||
"packaging",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue