mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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
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:
commit
e0ed2296b2
14 changed files with 818 additions and 54 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
57
apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py
Normal file
57
apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py
Normal 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,
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
216
apps/m3u/tests/test_expiration_notifications.py
Normal file
216
apps/m3u/tests/test_expiration_notifications.py
Normal 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}")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue