Merge pull request #1096 from Dispatcharr/xc-account-expiration-management
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run

Enhancement: Account expiration tracking and notifications for M3U profiles
This commit is contained in:
SergeantPanda 2026-03-15 16:42:33 -05:00 committed by GitHub
commit e0ed2296b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 818 additions and 54 deletions

View file

@ -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

View file

@ -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):

View file

@ -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,
),
]

View file

@ -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

View file

@ -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"""

View file

@ -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

View file

@ -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"
)

View file

@ -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}")

View file

@ -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"

View file

@ -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 {

View file

@ -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' && (
<FileInput
id="file"
label="Upload files"
placeholder="Upload files"
description="Upload a local M3U file instead of using URL"
onChange={setFile}
/>
<>
<FileInput
id="file"
label="Upload files"
placeholder="Upload files"
description="Upload a local M3U file instead of using URL"
onChange={setFile}
/>
<DateTimePicker
label="Expiration Date"
description="Set an expiration date to receive a warning notification"
placeholder="No expiration"
clearable
valueFormat="MMM D, YYYY h:mm A"
value={expDate}
onChange={(v) => setExpDate(v ? new Date(v) : null)}
/>
</>
)}
</Stack>

View file

@ -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 && (
<DateTimePicker
label="Expiration Date"
description="Set an expiration date to receive a 7-day warning notification"
placeholder="No expiration"
clearable
valueFormat="MMM D, YYYY h:mm A"
value={watch('exp_date')}
onChange={(value) => setValue('exp_date', value)}
/>
)}
<Textarea
label="Notes"
placeholder="Add any notes or comments about this profile..."

View file

@ -32,7 +32,12 @@ import {
SquarePlus,
} from 'lucide-react';
import useLocalStorage from '../../hooks/useLocalStorage';
import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js';
import {
useDateTimeFormat,
format,
diff,
getNow,
} from '../../utils/dateTimeUtils.js';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
@ -159,7 +164,7 @@ const M3UTable = () => {
const theme = useMantineTheme();
const [tableSize] = useLocalStorage('table-size', 'default');
const { fullDateTimeFormat } = useDateTimeFormat();
const { fullDateFormat, fullDateTimeFormat } = useDateTimeFormat();
const generateStatusString = (data) => {
if (data.progress == 100) {
@ -584,6 +589,64 @@ const M3UTable = () => {
sortable: true,
size: 125,
},
{
header: 'Expiration',
accessorKey: 'earliest_expiration',
sortable: true,
size: 110,
cell: ({ cell, row }) => {
const data = row.original;
const earliest = cell.getValue();
if (!earliest) {
return null;
}
const now = getNow();
const daysLeft = diff(earliest, now, 'day');
let color;
let label;
if (daysLeft < 0) {
color = 'red.7';
label = 'Expired';
} else if (daysLeft === 0) {
color = 'red.5';
label = 'Expires today';
} else if (daysLeft <= 7) {
color = 'orange.5';
label = `${daysLeft}d left`;
} else if (daysLeft <= 30) {
color = 'yellow.5';
label = `${daysLeft}d left`;
} else {
label = format(earliest, fullDateFormat);
}
const allExpirations = data.all_expirations || [];
const tooltipContent =
allExpirations.length > 0
? allExpirations
.map(
(e) =>
`${e.profile_name}: ${format(e.exp_date, fullDateTimeFormat)}${!e.is_active ? ' (inactive)' : ''}`
)
.join('\n')
: label;
return (
<Tooltip
label={tooltipContent}
multiline
width={300}
style={{ whiteSpace: 'pre-line' }}
>
<Text size="xs" c={color} fw={daysLeft <= 7 ? 600 : 400}>
{label}
</Text>
</Tooltip>
);
},
},
{
header: 'Updated',
accessorKey: 'updated_at',
@ -624,6 +687,7 @@ const M3UTable = () => {
editPlaylist,
deletePlaylist,
toggleActive,
fullDateFormat,
fullDateTimeFormat,
]
);
@ -696,13 +760,22 @@ const M3UTable = () => {
playlists
.filter((playlist) => playlist.locked === false)
.sort((a, b) => {
console.log(a);
console.log(newSorting[0].id);
if (a[compareColumn] !== b[compareColumn]) {
return compareDesc ? 1 : -1;
const aVal = a[compareColumn];
const bVal = b[compareColumn];
// Always sort nulls/undefined to the end regardless of direction
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
let comparison;
if (typeof aVal === 'string') {
comparison = aVal.localeCompare(bVal);
} else {
comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
}
return 0;
return compareDesc ? -comparison : comparison;
})
);
}
@ -778,6 +851,7 @@ const M3UTable = () => {
status: renderHeaderCell,
last_message: renderHeaderCell,
updated_at: renderHeaderCell,
earliest_expiration: renderHeaderCell,
is_active: renderHeaderCell,
actions: renderHeaderCell,
},

View file

@ -26,12 +26,15 @@ const useNotificationsStore = create((set, get) => ({
);
if (exists) {
// Update existing notification
const updatedNotifications = state.notifications.map((n) =>
n.notification_key === notification.notification_key
? { ...n, ...notification }
: n
);
return {
notifications: state.notifications.map((n) =>
n.notification_key === notification.notification_key
? { ...n, ...notification }
: n
),
notifications: updatedNotifications,
unreadCount: updatedNotifications.filter((n) => !n.is_dismissed)
.length,
};
}
// Add new notification