""" 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 getattr(user, 'user_level', 0) < 10: 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 getattr(user, 'user_level', 0) < 10: 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