Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
This commit is contained in:
@@ -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 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
|
||||
Reference in New Issue
Block a user