From b01eb9585ccf4120228fe449aa32aa1a4b86b0d6 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 3 Feb 2026 09:24:02 -0600 Subject: [PATCH] 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 --- CHANGELOG.md | 6 + core/api_urls.py | 2 + core/api_views.py | 157 +++++++ core/apps.py | 49 ++ core/developer_notifications.py | 412 +++++++++++++++++ core/fixtures/developer_notifications.json | 43 ++ ...ystemnotification_notificationdismissal.py | 52 +++ core/models.py | 150 ++++++ core/serializers.py | 49 +- core/signals.py | 34 +- core/tasks.py | 224 +++++++++ core/utils.py | 73 +++ dispatcharr/settings.py | 5 + frontend/src/WebSocket.jsx | 53 +++ frontend/src/api.js | 103 +++++ .../src/components/NotificationCenter.jsx | 429 ++++++++++++++++++ frontend/src/components/Sidebar.jsx | 55 ++- frontend/src/pages/Settings.jsx | 82 ++-- frontend/src/store/notifications.jsx | 111 +++++ pyproject.toml | 1 + 20 files changed, 2041 insertions(+), 49 deletions(-) create mode 100644 core/developer_notifications.py create mode 100644 core/fixtures/developer_notifications.json create mode 100644 core/migrations/0021_systemnotification_notificationdismissal.py create mode 100644 frontend/src/components/NotificationCenter.jsx create mode 100644 frontend/src/store/notifications.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3966174a..8b3f4f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/core/api_urls.py b/core/api_urls.py index 75257db1..1ac58455 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -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'), diff --git a/core/api_views.py b/core/api_views.py index 44001f95..20ef6249 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -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 + }) + diff --git a/core/apps.py b/core/apps.py index 96ccfb3b..12f6d054 100644 --- a/core/apps.py +++ b/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}") diff --git a/core/developer_notifications.py b/core/developer_notifications.py new file mode 100644 index 00000000..cf16e7a2 --- /dev/null +++ b/core/developer_notifications.py @@ -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 diff --git a/core/fixtures/developer_notifications.json b/core/fixtures/developer_notifications.json new file mode 100644 index 00000000..0040682e --- /dev/null +++ b/core/fixtures/developer_notifications.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/core/migrations/0021_systemnotification_notificationdismissal.py b/core/migrations/0021_systemnotification_notificationdismissal.py new file mode 100644 index 00000000..66eb9b18 --- /dev/null +++ b/core/migrations/0021_systemnotification_notificationdismissal.py @@ -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')}, + }, + ), + ] diff --git a/core/models.py b/core/models.py index 683acb0d..a694a76b 100644 --- a/core/models.py +++ b/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}" diff --git a/core/serializers.py b/core/serializers.py index b2bd8ecc..03ba33e8 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -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'] diff --git a/core/signals.py b/core/signals.py index 6844a890..bbb73dd9 100644 --- a/core/signals.py +++ b/core/signals.py @@ -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}") diff --git a/core/tasks.py b/core/tasks.py index 78a3e0ec..44ddc68a 100644 --- a/core/tasks.py +++ b/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 + diff --git a/core/utils.py b/core/utils.py index e3d6c389..5c5a0045 100644 --- a/core/utils.py +++ b/core/utils.py @@ -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}") + diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index b65ee2d0..7af6e939 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -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" diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 034418f6..2d6cc0d9 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -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}` diff --git a/frontend/src/api.js b/frontend/src/api.js index a831ad22..676e97d8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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); + } + } } diff --git a/frontend/src/components/NotificationCenter.jsx b/frontend/src/components/NotificationCenter.jsx new file mode 100644 index 00000000..f7da56ad --- /dev/null +++ b/frontend/src/components/NotificationCenter.jsx @@ -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 ; + case 'setting_recommendation': + return ; + case 'announcement': + return ; + case 'warning': + return ; + case 'info': + default: + return ; + } +}; + +// 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 ( + + {/* Dismiss button for non-setting notifications (only if not already dismissed) */} + {notification.notification_type !== 'setting_recommendation' && + !isDismissed && ( + + + + )} + + + + {getNotificationIcon(notification.notification_type)} + + + + + {notification.title} + + {isDismissed && ( + + Dismissed + + )} + {notification.priority === 'high' || + notification.priority === 'critical' ? ( + + {notification.priority} + + ) : null} + + + {notification.message} + + + {/* Action buttons for specific notification types */} + {notification.notification_type === 'version_update' && + notification.action_data?.release_url && ( + + )} + + {/* Generic action button for notifications with action_url/action_text */} + {notification.action_data?.action_url && + notification.action_data?.action_text && ( + + )} + + {notification.notification_type === 'setting_recommendation' && + !notification.action_data?.action_url && ( + + + + + )} + + + + + {new Date(notification.created_at).toLocaleDateString()} + + + ); +}; + +// 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 ( + + + 9 ? '9+' : unreadCount} + disabled={unreadCount === 0} + offset={4} + processing={unreadCount > 0} + > + setOpened((o) => !o)} + aria-label="Notifications" + > + + + + + + + {/* Header */} + + + + Notifications + + {unreadCount > 0 && ( + + {unreadCount} new + + )} + + + + setShowDismissed((prev) => !prev)} + > + {showDismissed ? : } + + + {unreadCount > 0 && ( + + + + + + )} + + + + + + {/* Notification list */} + + {displayedNotifications.length === 0 ? ( + + + + + + {showDismissed + ? 'No dismissed notifications' + : 'All caught up!'} + + + {showDismissed + ? 'Dismissed notifications appear here' + : 'No new notifications'} + + + ) : ( + + {displayedNotifications.map((notification) => ( + setOpened(false)} + /> + ))} + + )} + + + {/* Footer with info text */} + {!showDismissed && + notifications.length > unreadNotifications.length && ( + <> + + + + {notifications.length - unreadNotifications.length} dismissed + notification + {notifications.length - unreadNotifications.length !== 1 + ? 's' + : ''} + + + + )} + + + ); +}; + +export default NotificationCenter; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 435509e0..6c997cfe 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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 && ( - + {!collapsed && ( { /> )} - {!collapsed && authUser && ( - setUserFormOpen(true)}> - {authUser.first_name || authUser.username} - - + + + setUserFormOpen(true)}> + {authUser.first_name || authUser.username} + + )} - + {collapsed && ( + + + + )} + )} - {/* Version is always shown when sidebar is expanded, regardless of auth status */} + {/* Version and Notification */} {!collapsed && ( - - v{appVersion?.version || '0.0.0'} - {appVersion?.timestamp ? `-${appVersion.timestamp}` : ''} - + + + v{appVersion?.version || '0.0.0'} + {appVersion?.timestamp ? `-${appVersion.timestamp}` : ''} + + {isAuthenticated && } + + )} + {collapsed && isAuthenticated && ( + + + )} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 4ce519a3..a2ee0545 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 (
UI Settings - + @@ -60,7 +77,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'dvr-settings'} + /> @@ -72,7 +90,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'stream-settings'} + /> @@ -84,7 +103,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'system-settings'} + /> @@ -96,7 +116,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'user-agents'} + /> @@ -108,7 +129,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'stream-profiles'} + /> @@ -127,7 +149,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'network-access'} + /> @@ -141,7 +164,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'proxy-settings'} + /> diff --git a/frontend/src/store/notifications.jsx b/frontend/src/store/notifications.jsx new file mode 100644 index 00000000..438b0cbc --- /dev/null +++ b/frontend/src/store/notifications.jsx @@ -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; diff --git a/pyproject.toml b/pyproject.toml index 9400a55d..82961ccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "django-filter", "django-celery-beat", "lxml==6.0.2", + "packaging", ] [build-system]