diff --git a/CHANGELOG.md b/CHANGELOG.md index 6571bfcf..232521b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Search and filter controls**: Added search and filter controls to the recordings list. - **Stream generator throttling**: Cached the `ProxyServer` singleton reference per client and throttled Redis resource checks (1 s) and non-owner health checks (2 s), eliminating 3+ Redis round-trips per stream loop iteration. - **Automatic crash recovery on worker restart**: A `worker_ready` Celery signal now fires `recover_recordings_on_startup` automatically when the worker starts, so recordings stuck in "recording" status are recovered without manual intervention. +- Account expiration tracking and notifications for M3U profiles + - A new `exp_date` field on `M3UAccountProfile` stores the account expiration date as a proper `DateTimeField`. For Xtream Codes accounts the field is auto-synced from `custom_properties.user_info.exp_date` on every save (supports both Unix timestamps and ISO date strings). For non-XC M3U accounts the date can be entered manually via the account or profile form. + - The M3U accounts table now shows an **Expiration** column displaying the earliest expiration date across all profiles for that account (color-coded: red = expired, orange = expiring soon, green = OK). Hovering the cell shows a tooltip with per-profile expiration details including inactive-profile labels. + - A daily Celery Beat task (`check_xc_account_expirations`) checks all active profiles with an expiration date and manages system notifications: a normal-priority warning is raised for profiles expiring within 7 days; a high-priority alert is raised once the profile has already expired. Warning and expired notifications use separate keys so dismissing the 7-day warning does not suppress the expiration alert. + - Notifications are also updated immediately when a profile is saved: if the expiration date is cleared or pushed beyond the 7-day window, any existing warning/expired notifications are deleted; if the date falls within the window or is already past, the matching notification is updated in place. + - Non-XC accounts expose a `DateTimePicker` on both the M3U account form and the profile form. ### Changed diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 34f3cd77..3b856dc8 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -40,7 +40,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet): queryset = M3UAccount.objects.select_related( "refresh_task__crontab", "refresh_task__interval" - ).prefetch_related("channel_group") + ).prefetch_related("channel_group", "profiles") serializer_class = M3UAccountSerializer def get_permissions(self): diff --git a/apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py b/apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py new file mode 100644 index 00000000..bae4d9dd --- /dev/null +++ b/apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py @@ -0,0 +1,57 @@ +# Generated by Django 6.0.3 on 2026-03-14 19:41 + +from datetime import datetime, timezone + +from django.db import migrations, models + + +def populate_exp_date_from_custom_properties(apps, schema_editor): + """Backfill exp_date from custom_properties['user_info']['exp_date'].""" + M3UAccountProfile = apps.get_model('m3u', 'M3UAccountProfile') + profiles_to_update = [] + + for profile in M3UAccountProfile.objects.filter( + custom_properties__isnull=False, + ).exclude(custom_properties={}): + user_info = profile.custom_properties.get('user_info', {}) + raw_exp = user_info.get('exp_date') + if raw_exp is None: + continue + + parsed = None + try: + if isinstance(raw_exp, (int, float)): + parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc) + elif isinstance(raw_exp, str): + try: + parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc) + except ValueError: + parsed = datetime.fromisoformat(raw_exp) + except (ValueError, TypeError, OSError): + pass + + if parsed is not None: + profile.exp_date = parsed + profiles_to_update.append(profile) + + if profiles_to_update: + M3UAccountProfile.objects.bulk_update(profiles_to_update, ['exp_date'], batch_size=500) + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0018_add_profile_custom_properties'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccountprofile', + name='exp_date', + field=models.DateTimeField(blank=True, help_text='Account expiration date, auto-synced from custom_properties on save', null=True), + ), + migrations.RunPython( + populate_exp_date_from_custom_properties, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index b812ad6c..fbe22d8c 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from django.db import models from django.core.exceptions import ValidationError from core.models import UserAgent @@ -264,11 +265,16 @@ class M3UAccountProfile(models.Model): ) current_viewers = models.PositiveIntegerField(default=0) custom_properties = models.JSONField( - default=dict, - blank=True, - null=True, + default=dict, + blank=True, + null=True, help_text="Custom properties for storing account information from provider (e.g., XC account details, expiration dates)" ) + exp_date = models.DateTimeField( + null=True, + blank=True, + help_text="Account expiration date, auto-synced from custom_properties on save", + ) class Meta: constraints = [ @@ -280,36 +286,51 @@ class M3UAccountProfile(models.Model): def __str__(self): return f"{self.name} ({self.m3u_account.name})" - def get_account_expiration(self): - """Get account expiration date from custom properties if available""" + def save(self, *args, **kwargs): + """Auto-sync exp_date from custom_properties for XC accounts on every save. + For non-XC accounts, exp_date is set directly and left untouched here.""" + parsed = self._parse_exp_date_from_custom_properties() + if parsed is not None: + # XC account with exp_date in custom_properties — always sync + self.exp_date = parsed + # else: keep whatever exp_date is already set (manual entry for non-XC) + super().save(*args, **kwargs) + + @staticmethod + def _parse_exp_date(raw_value): + """Parse a raw exp_date value (unix timestamp or ISO string) into a datetime.""" + if raw_value is None: + return None + try: + if isinstance(raw_value, (int, float)): + return datetime.fromtimestamp(float(raw_value), tz=timezone.utc) + elif isinstance(raw_value, str): + try: + return datetime.fromtimestamp(float(raw_value), tz=timezone.utc) + except ValueError: + return datetime.fromisoformat(raw_value) + except (ValueError, TypeError, OSError): + pass + return None + + def _parse_exp_date_from_custom_properties(self): + """Extract exp_date from custom_properties JSON.""" if not self.custom_properties: return None - user_info = self.custom_properties.get('user_info', {}) - exp_date = user_info.get('exp_date') - - if exp_date: - try: - from datetime import datetime - # XC exp_date is typically a Unix timestamp - if isinstance(exp_date, (int, float)): - return datetime.fromtimestamp(exp_date) - elif isinstance(exp_date, str): - # Try to parse as timestamp first, then as ISO date - try: - return datetime.fromtimestamp(float(exp_date)) - except ValueError: - return datetime.fromisoformat(exp_date) - except (ValueError, TypeError): - pass - - return None + return self._parse_exp_date(user_info.get('exp_date')) + + def get_account_expiration(self): + """Get account expiration date — uses the dedicated field if set, otherwise parses JSON.""" + if self.exp_date: + return self.exp_date + return self._parse_exp_date_from_custom_properties() def get_account_status(self): """Get account status from custom properties if available""" if not self.custom_properties: return None - + user_info = self.custom_properties.get('user_info', {}) return user_info.get('status') @@ -317,7 +338,7 @@ class M3UAccountProfile(models.Model): """Get maximum connections from custom properties if available""" if not self.custom_properties: return None - + user_info = self.custom_properties.get('user_info', {}) return user_info.get('max_connections') @@ -325,7 +346,7 @@ class M3UAccountProfile(models.Model): """Get active connections from custom properties if available""" if not self.custom_properties: return None - + user_info = self.custom_properties.get('user_info', {}) return user_info.get('active_cons') @@ -333,7 +354,7 @@ class M3UAccountProfile(models.Model): """Get last refresh timestamp from custom properties if available""" if not self.custom_properties: return None - + last_refresh = self.custom_properties.get('last_refresh') if last_refresh: try: @@ -341,7 +362,7 @@ class M3UAccountProfile(models.Model): return datetime.fromisoformat(last_refresh) except (ValueError, TypeError): pass - + return None diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 22c4057a..587d98cb 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -7,6 +7,7 @@ from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount from apps.channels.serializers import ( ChannelGroupM3UAccountSerializer, ) +from datetime import timezone as dt_tz import logging import json @@ -52,12 +53,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer): "search_pattern", "replace_pattern", "custom_properties", + "exp_date", "account", ] read_only_fields = ["id", "account"] extra_kwargs = { 'search_pattern': {'required': False, 'allow_blank': True}, 'replace_pattern': {'required': False, 'allow_blank': True}, + 'exp_date': {'required': False, 'allow_null': True}, } def create(self, validated_data): @@ -90,14 +93,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): if instance.is_default: - # For default profiles, only allow updating name and custom_properties (for notes) - allowed_fields = {'name', 'custom_properties'} + # For default profiles, only allow updating name, custom_properties, and exp_date + allowed_fields = {'name', 'custom_properties', 'exp_date'} # Remove any fields that aren't allowed for default profiles disallowed_fields = set(validated_data.keys()) - allowed_fields if disallowed_fields: raise serializers.ValidationError( - f"Default profiles can only modify name and notes. " + f"Default profiles can only modify name, notes, and expiration. " f"Cannot modify: {', '.join(disallowed_fields)}" ) @@ -117,6 +120,12 @@ class M3UAccountSerializer(serializers.ModelSerializer): """Serializer for M3U Account""" filters = serializers.SerializerMethodField() + earliest_expiration = serializers.SerializerMethodField() + all_expirations = serializers.SerializerMethodField() + exp_date = serializers.DateTimeField( + required=False, allow_null=True, write_only=True, + help_text="Expiration date for the default profile (write-through)", + ) # Include user_agent as a mandatory field using its primary key. user_agent = serializers.PrimaryKeyRelatedField( queryset=UserAgent.objects.all(), @@ -172,6 +181,9 @@ class M3UAccountSerializer(serializers.ModelSerializer): "auto_enable_new_groups_live", "auto_enable_new_groups_vod", "auto_enable_new_groups_series", + "earliest_expiration", + "all_expirations", + "exp_date", ] extra_kwargs = { "password": { @@ -200,9 +212,24 @@ class M3UAccountSerializer(serializers.ModelSerializer): ct = instance.refresh_task.crontab cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" data["cron_expression"] = cron_expr + + # Surface default profile's exp_date for the form. + # Use prefetch cache (obj.profiles.all()) to avoid an extra query per account. + # Always emit a Z-suffix UTC string so JS new Date() never misinterprets it as local. + default_profile = next((p for p in instance.profiles.all() if p.is_default), None) + exp = default_profile.exp_date if default_profile else None + if exp: + exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc) + data["exp_date"] = exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ') + else: + data["exp_date"] = None + return data def update(self, instance, validated_data): + # Pop exp_date — it's written to the default profile, not the account + exp_date = validated_data.pop("exp_date", "__NOT_SET__") + # Pop cron_expression before it reaches model fields # If not present (partial update), preserve the existing cron from the PeriodicTask if "cron_expression" in validated_data: @@ -264,9 +291,26 @@ class M3UAccountSerializer(serializers.ModelSerializer): memberships_to_update, ["enabled"] ) + # Write exp_date through to the default profile. + # Use a fresh DB query (not the prefetch cache) so we get the profile + # object AFTER the post_save signal (create_profile_for_m3u_account) + # has already updated max_streams, avoiding a stale-value overwrite. + if exp_date != "__NOT_SET__": + default_profile = instance.profiles.filter(is_default=True).first() + if default_profile: + default_profile.exp_date = exp_date + default_profile.save(update_fields=['exp_date']) + # Invalidate the profiles prefetch cache so to_representation + # sees the updated exp_date rather than the pre-request snapshot. + if '_prefetched_objects_cache' in instance.__dict__: + instance._prefetched_objects_cache.pop('profiles', None) + return instance def create(self, validated_data): + # Pop exp_date — it's written to the default profile after creation + exp_date = validated_data.pop("exp_date", None) + # Pop cron_expression — it's not a model field cron_expr = validated_data.pop("cron_expression", "") @@ -290,12 +334,47 @@ class M3UAccountSerializer(serializers.ModelSerializer): instance = M3UAccount(**validated_data) instance._cron_expression = cron_expr instance.save() + + # Write exp_date through to the default profile created by post_save signal + if exp_date is not None: + default_profile = instance.profiles.filter(is_default=True).first() + if default_profile: + default_profile.exp_date = exp_date + default_profile.save() + return instance def get_filters(self, obj): filters = obj.filters.order_by("order") return M3UFilterSerializer(filters, many=True).data + def get_earliest_expiration(self, obj): + """Return the soonest exp_date across all active profiles for this account.""" + # Filter in Python over the prefetch cache to avoid an extra query per account. + expiring = [p.exp_date for p in obj.profiles.all() if p.is_active and p.exp_date] + if not expiring: + return None + exp = min(expiring) + exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc) + return exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ') + + def get_all_expirations(self, obj): + """Return exp_date info for every profile that has one (for tooltip).""" + # Filter in Python over the prefetch cache to avoid an extra query per account. + profiles = sorted( + (p for p in obj.profiles.all() if p.exp_date), + key=lambda p: p.exp_date, + ) + return [ + { + "profile_id": p.id, + "profile_name": p.name, + "exp_date": (p.exp_date.astimezone(dt_tz.utc) if p.exp_date.tzinfo else p.exp_date.replace(tzinfo=dt_tz.utc)).strftime('%Y-%m-%dT%H:%M:%SZ'), + "is_active": p.is_active, + } + for p in profiles + ] + class ServerGroupSerializer(serializers.ModelSerializer): """Serializer for Server Group""" diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index 3de67c90..ce24d015 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -1,7 +1,7 @@ # apps/m3u/signals.py from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver -from .models import M3UAccount +from .models import M3UAccount, M3UAccountProfile from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id from core.scheduling import create_or_update_periodic_task, delete_periodic_task import json @@ -68,6 +68,62 @@ def create_or_update_refresh_task(sender, instance, created, update_fields=None, if instance.refresh_task_id != task.id: M3UAccount.objects.filter(id=instance.id).update(refresh_task=task) +@receiver(post_save, sender=M3UAccountProfile) +def update_profile_expiration_notification(sender, instance, created, update_fields=None, **kwargs): + """ + When a profile's exp_date is set or changed, immediately update its expiration notification + so the frontend reflects the new state without waiting for the daily celery task. + """ + # Only act when exp_date was involved in the save + if not created and update_fields is not None and "exp_date" not in update_fields: + return + + try: + if not instance.exp_date: + # exp_date was cleared — remove any existing notifications immediately + from core.models import SystemNotification + from core.utils import send_notification_dismissed + + keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"] + deleted_keys = list( + SystemNotification.objects.filter(notification_key__in=keys) + .values_list("notification_key", flat=True) + ) + SystemNotification.objects.filter(notification_key__in=deleted_keys).delete() + for key in deleted_keys: + send_notification_dismissed(key) + return + + from apps.m3u.tasks import evaluate_profile_expiration_notification + evaluate_profile_expiration_notification(instance) + except Exception as e: + logger.error(f"Error updating expiration notification for profile {instance.id}: {str(e)}") + + +@receiver(post_delete, sender=M3UAccountProfile) +def cleanup_profile_notifications(sender, instance, **kwargs): + """ + Delete expiration notifications for a profile when it is deleted. + Handles both direct deletion and cascade deletion from M3UAccount. + """ + try: + from core.models import SystemNotification + from core.utils import send_notification_dismissed + + keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"] + deleted_keys = list( + SystemNotification.objects.filter(notification_key__in=keys) + .values_list("notification_key", flat=True) + ) + if deleted_keys: + SystemNotification.objects.filter(notification_key__in=deleted_keys).delete() + for key in deleted_keys: + send_notification_dismissed(key) + logger.debug(f"Cleaned up {len(deleted_keys)} notifications for deleted profile {instance.id}") + except Exception as e: + logger.error(f"Error cleaning up notifications for profile {instance.id}: {str(e)}") + + @receiver(post_delete, sender=M3UAccount) def delete_refresh_task(sender, instance, **kwargs): """ @@ -107,6 +163,34 @@ def update_status_on_active_change(sender, instance, **kwargs): else: # When deactivating, set status to disabled instance.status = M3UAccount.Status.DISABLED + # Clean up any expiration notifications for all profiles of this account + try: + from core.models import SystemNotification + from core.utils import send_notification_dismissed + + profile_ids = list( + M3UAccountProfile.objects.filter(m3u_account=instance) + .values_list("id", flat=True) + ) + keys = [ + key + for pid in profile_ids + for key in [f"xc-exp-warning-{pid}", f"xc-exp-expired-{pid}"] + ] + if keys: + deleted_keys = list( + SystemNotification.objects.filter(notification_key__in=keys) + .values_list("notification_key", flat=True) + ) + if deleted_keys: + SystemNotification.objects.filter(notification_key__in=deleted_keys).delete() + for key in deleted_keys: + send_notification_dismissed(key) + logger.debug( + f"Cleaned up {len(deleted_keys)} notifications for deactivated M3U account {instance.id}" + ) + except Exception as notify_err: + logger.error(f"Error cleaning up notifications on account deactivation: {notify_err}") except M3UAccount.DoesNotExist: # New record, will use default status pass diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 5259cefc..9c31fa75 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -11,7 +11,7 @@ from celery.result import AsyncResult from celery import shared_task, current_app, group from django.conf import settings from django.core.cache import cache -from django.db import transaction +from django.db import models, transaction from .models import M3UAccount from apps.channels.models import Stream, ChannelGroup, ChannelGroupM3UAccount from asgiref.sync import async_to_sync @@ -3185,3 +3185,163 @@ def send_m3u_update(account_id, action, progress, **kwargs): # Explicitly clear data reference to help garbage collection data = None + + +def evaluate_profile_expiration_notification(profile): + """ + Evaluate a single M3UAccountProfile's expiration date and create, update, + or delete the corresponding SystemNotification accordingly. + + Returns the notification key that should remain active (warning or expired), + or None if the profile is not expiring soon and any stale notifications were removed. + This return value is used by the bulk task to track active keys for stale cleanup. + """ + from core.models import SystemNotification + from core.utils import send_websocket_notification, send_notification_dismissed + + exp = profile.exp_date + if not exp: + return None + + now = timezone.now() + warning_threshold = now + timezone.timedelta(days=7) + warning_key = f"xc-exp-warning-{profile.id}" + expired_key = f"xc-exp-expired-{profile.id}" + + if exp <= now: + # Already expired — delete warning, create/update expired notification + deleted_warning = list( + SystemNotification.objects.filter(notification_key=warning_key) + .values_list("notification_key", flat=True) + ) + SystemNotification.objects.filter(notification_key=warning_key).delete() + for key in deleted_warning: + send_notification_dismissed(key) + + notification, created = SystemNotification.objects.update_or_create( + notification_key=expired_key, + defaults={ + "notification_type": SystemNotification.NotificationType.WARNING, + "priority": SystemNotification.Priority.HIGH, + "title": f"Account Expired: {profile.name}", + "message": ( + f'Profile "{profile.name}" on M3U account ' + f'"{profile.m3u_account.name}" has expired ' + f"(expired {exp.strftime('%Y-%m-%d %H:%M UTC')})." + ), + "action_data": { + "profile_id": profile.id, + "account_id": profile.m3u_account.id, + "account_name": profile.m3u_account.name, + "profile_name": profile.name, + "exp_date": exp.isoformat(), + }, + "is_active": True, + "admin_only": True, + }, + ) + send_websocket_notification(notification) + return expired_key + + elif exp <= warning_threshold: + # Expiring within 7 days — delete expired notification, create/update warning + deleted_expired = list( + SystemNotification.objects.filter(notification_key=expired_key) + .values_list("notification_key", flat=True) + ) + SystemNotification.objects.filter(notification_key=expired_key).delete() + for key in deleted_expired: + send_notification_dismissed(key) + + days_left = (exp - now).days + if days_left == 0: + expires_in_str = "today" + elif days_left == 1: + expires_in_str = "in 1 day" + else: + expires_in_str = f"in {days_left} days" + + notification, created = SystemNotification.objects.update_or_create( + notification_key=warning_key, + defaults={ + "notification_type": SystemNotification.NotificationType.WARNING, + "priority": SystemNotification.Priority.NORMAL, + "title": f"Account Expiring: {profile.name}", + "message": ( + f'Profile "{profile.name}" on M3U account ' + f'"{profile.m3u_account.name}" expires {expires_in_str} ' + f"(expires {exp.strftime('%Y-%m-%d %H:%M UTC')})." + ), + "action_data": { + "profile_id": profile.id, + "account_id": profile.m3u_account.id, + "account_name": profile.m3u_account.name, + "profile_name": profile.name, + "exp_date": exp.isoformat(), + }, + "is_active": True, + "admin_only": True, + }, + ) + send_websocket_notification(notification) + return warning_key + + else: + # Not expiring soon — delete any stale notifications + deleted_keys = list( + SystemNotification.objects.filter( + notification_key__in=[warning_key, expired_key] + ).values_list("notification_key", flat=True) + ) + SystemNotification.objects.filter( + notification_key__in=[warning_key, expired_key] + ).delete() + for key in deleted_keys: + send_notification_dismissed(key) + return None + + +@shared_task +def check_account_expirations(): + """ + Daily task: check all account profiles for upcoming expirations. + Creates/updates SystemNotifications for profiles expiring within 7 days. + Uses separate notification keys for warning vs expired so users can + dismiss the 7-day warning and still receive the expired notification. + """ + from apps.m3u.models import M3UAccountProfile + from core.models import SystemNotification + from core.utils import send_notification_dismissed + + # Find all active profiles with an exp_date that is set + expiring_profiles = ( + M3UAccountProfile.objects.filter( + m3u_account__is_active=True, + is_active=True, + exp_date__isnull=False, + ) + .select_related("m3u_account") + ) + + active_notification_keys = set() + + for profile in expiring_profiles: + active_key = evaluate_profile_expiration_notification(profile) + if active_key: + active_notification_keys.add(active_key) + + # Delete stale notifications for profiles whose expiration was extended + stale = SystemNotification.objects.filter( + is_active=True, + ).filter( + models.Q(notification_key__startswith="xc-exp-warning-") | + models.Q(notification_key__startswith="xc-exp-expired-") + ).exclude(notification_key__in=active_notification_keys) + stale_keys = list(stale.values_list("notification_key", flat=True)) + stale.delete() + for key in stale_keys: + send_notification_dismissed(key) + + logger.info( + f"Account expiration check complete: {len(active_notification_keys)} active notifications" + ) diff --git a/apps/m3u/tests/test_expiration_notifications.py b/apps/m3u/tests/test_expiration_notifications.py new file mode 100644 index 00000000..c734d3d2 --- /dev/null +++ b/apps/m3u/tests/test_expiration_notifications.py @@ -0,0 +1,216 @@ +""" +Tests for evaluate_profile_expiration_notification. + +Covers all four branches: + - no exp_date → returns None, touches nothing + - already expired → creates/updates expired notification, removes warning + - expiring within 7d → creates/updates warning notification, removes expired + - not expiring soon → removes any stale notifications, returns None +""" +from datetime import timedelta +from unittest.mock import patch, MagicMock + +from django.test import SimpleTestCase +from django.utils import timezone + + +def _make_profile(exp_date, profile_id=1, profile_name="Test Profile", + account_id=10, account_name="Test Account"): + """Return a minimal mock M3UAccountProfile.""" + profile = MagicMock() + profile.id = profile_id + profile.name = profile_name + profile.exp_date = exp_date + profile.m3u_account.id = account_id + profile.m3u_account.name = account_name + return profile + + +class EvaluateProfileExpirationNotificationTests(SimpleTestCase): + + def setUp(self): + # These three names are local imports inside evaluate_profile_expiration_notification, + # so we must patch them at their source modules rather than on apps.m3u.tasks. + self.mock_sn = MagicMock() + self.mock_send_ws = patch("core.utils.send_websocket_notification").start() + self.mock_dismissed = patch("core.utils.send_notification_dismissed").start() + patch("core.models.SystemNotification", self.mock_sn).start() + + def tearDown(self): + patch.stopall() + + def _run(self, profile): + from apps.m3u.tasks import evaluate_profile_expiration_notification + return evaluate_profile_expiration_notification(profile) + + # ------------------------------------------------------------------ # + # No expiration date + # ------------------------------------------------------------------ # + + def test_no_exp_date_returns_none(self): + profile = _make_profile(exp_date=None) + result = self._run(profile) + self.assertIsNone(result) + self.mock_sn.objects.update_or_create.assert_not_called() + self.mock_send_ws.assert_not_called() + + # ------------------------------------------------------------------ # + # Already expired + # ------------------------------------------------------------------ # + + @patch("apps.m3u.tasks.timezone") + def test_expired_creates_expired_notification(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now - timedelta(days=1)) + # No existing warning notification to delete + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + notification = MagicMock() + self.mock_sn.objects.update_or_create.return_value = (notification, True) + + result = self._run(profile) + + self.assertEqual(result, f"xc-exp-expired-{profile.id}") + self.mock_sn.objects.update_or_create.assert_called_once() + call_kwargs = self.mock_sn.objects.update_or_create.call_args + self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-expired-{profile.id}") + self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"]) + self.mock_send_ws.assert_called_once_with(notification) + + @patch("apps.m3u.tasks.timezone") + def test_expired_removes_stale_warning_notification(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now - timedelta(hours=1)) + warning_key = f"xc-exp-warning-{profile.id}" + # Simulate an existing warning notification + self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False) + + self._run(profile) + + self.mock_dismissed.assert_any_call(warning_key) + + # ------------------------------------------------------------------ # + # Expiring within 7 days + # ------------------------------------------------------------------ # + + @patch("apps.m3u.tasks.timezone") + def test_warning_window_creates_warning_notification(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(days=3)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + notification = MagicMock() + self.mock_sn.objects.update_or_create.return_value = (notification, True) + + result = self._run(profile) + + self.assertEqual(result, f"xc-exp-warning-{profile.id}") + call_kwargs = self.mock_sn.objects.update_or_create.call_args + self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-warning-{profile.id}") + self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"]) + self.mock_send_ws.assert_called_once_with(notification) + + @patch("apps.m3u.tasks.timezone") + def test_warning_message_says_today_when_same_day(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(hours=2)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True) + + self._run(profile) + + defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"] + self.assertIn("today", defaults["message"]) + + @patch("apps.m3u.tasks.timezone") + def test_warning_message_says_1_day(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(hours=30)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True) + + self._run(profile) + + defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"] + self.assertIn("in 1 day", defaults["message"]) + + @patch("apps.m3u.tasks.timezone") + def test_warning_removes_stale_expired_notification(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(days=5)) + expired_key = f"xc-exp-expired-{profile.id}" + self.mock_sn.objects.filter.return_value.values_list.return_value = [expired_key] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False) + + self._run(profile) + + self.mock_dismissed.assert_any_call(expired_key) + + # ------------------------------------------------------------------ # + # Not expiring soon (> 7 days away) + # ------------------------------------------------------------------ # + + @patch("apps.m3u.tasks.timezone") + def test_not_expiring_soon_returns_none(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(days=30)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + + result = self._run(profile) + + self.assertIsNone(result) + self.mock_sn.objects.update_or_create.assert_not_called() + self.mock_send_ws.assert_not_called() + + @patch("apps.m3u.tasks.timezone") + def test_not_expiring_soon_removes_stale_notifications(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(days=30)) + warning_key = f"xc-exp-warning-{profile.id}" + self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key] + + self._run(profile) + + self.mock_dismissed.assert_called_once_with(warning_key) + + # ------------------------------------------------------------------ # + # Boundary: exactly at the 7-day warning threshold + # ------------------------------------------------------------------ # + + @patch("apps.m3u.tasks.timezone") + def test_exactly_7_days_away_triggers_warning(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + # exp_date == now + 7 days → exp <= warning_threshold → warning + profile = _make_profile(exp_date=now + timedelta(days=7)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True) + + result = self._run(profile) + + self.assertEqual(result, f"xc-exp-warning-{profile.id}") diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 50bac5d5..b0d387b2 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -267,6 +267,11 @@ CELERY_BEAT_SCHEDULE = { "task": "core.tasks.check_for_version_update", "schedule": 86400.0, # Once every 24 hours }, + # Check for account expirations daily + "check-account-expirations": { + "task": "apps.m3u.tasks.check_account_expirations", + "schedule": 86400.0, # Once every 24 hours + }, } MEDIA_ROOT = BASE_DIR / "media" diff --git a/frontend/src/api.js b/frontend/src/api.js index 4fa3d46c..b1c103d8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1286,6 +1286,7 @@ export default class API { body = new FormData(); for (const prop in values) { + if (values[prop] === null || values[prop] === undefined) continue; body.append(prop, values[prop]); } } else { diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 4b3522af..c8caeaef 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -29,6 +29,7 @@ import useEPGsStore from '../../store/epgs'; import useVODStore from '../../store/useVODStore'; import M3UFilters from './M3UFilters'; import ScheduleInput from './ScheduleInput'; +import { DateTimePicker } from '@mantine/dates'; const M3U = ({ m3uAccount = null, @@ -45,6 +46,7 @@ const M3U = ({ const [playlist, setPlaylist] = useState(null); const [file, setFile] = useState(null); + const [expDate, setExpDate] = useState(null); const [profileModalOpen, setProfileModalOpen] = useState(false); const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false); const [filterModalOpen, setFilterModalOpen] = useState(false); @@ -102,6 +104,7 @@ const M3U = ({ : 0, enable_vod: m3uAccount.enable_vod || false, }); + setExpDate(m3uAccount.exp_date ? new Date(m3uAccount.exp_date) : null); // Determine schedule type from existing data setScheduleType( @@ -119,6 +122,7 @@ const M3U = ({ setPlaylist(null); form.reset(); setScheduleType('interval'); + setExpDate(null); } }, [m3uAccount]); @@ -131,6 +135,16 @@ const M3U = ({ const onSubmit = async () => { const { create_epg, ...values } = form.getValues(); + // Convert exp_date (from controlled state) to ISO string for the API + if (values.account_type === 'XC') { + // XC accounts have exp_date auto-managed server-side; don't send it + delete values.exp_date; + } else if (expDate instanceof Date) { + values.exp_date = expDate.toISOString(); + } else { + values.exp_date = null; + } + // Determine which schedule type is active based on field values const hasCronExpression = values.cron_expression && values.cron_expression.trim() !== ''; @@ -359,13 +373,25 @@ const M3U = ({ )} {form.getValues().account_type != 'XC' && ( - + <> + + + setExpDate(v ? new Date(v) : null)} + /> + )} diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index 025d3cae..7c24b212 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -16,6 +16,7 @@ import { Textarea, NumberInput, } from '@mantine/core'; +import { DateTimePicker } from '@mantine/dates'; import { useWebSocket } from '../../WebSocket'; import usePlaylistsStore from '../../store/playlists'; @@ -32,6 +33,8 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { const [sampleInput, setSampleInput] = useState(''); const isDefaultProfile = profile?.is_default; + const isXC = m3u?.account_type === 'XC'; + const defaultValues = useMemo( () => ({ name: profile?.name || '', @@ -39,6 +42,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { search_pattern: profile?.search_pattern || '', replace_pattern: profile?.replace_pattern || '', notes: profile?.custom_properties?.notes || '', + exp_date: profile?.exp_date ? new Date(profile.exp_date) : null, }), [profile] ); @@ -73,6 +77,17 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { const onSubmit = async (values) => { console.log('submiting'); + // Convert exp_date for submission + let expDateValue = values.exp_date; + if (isXC) { + // XC accounts have exp_date auto-managed; don't send it + expDateValue = undefined; + } else if (expDateValue instanceof Date) { + expDateValue = expDateValue.toISOString(); + } else if (!expDateValue) { + expDateValue = null; + } + // For default profiles, only send name and custom_properties (notes) let submitValues; if (isDefaultProfile) { @@ -99,6 +114,11 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { }; } + // Add exp_date for non-XC accounts + if (expDateValue !== undefined) { + submitValues.exp_date = expDateValue; + } + if (profile?.id) { await API.updateM3UProfile(m3u.id, { id: profile.id, @@ -257,6 +277,18 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { )} + {!isXC && ( + setValue('exp_date', value)} + /> + )} +