forked from Mirrors/Dispatcharr
Pre-Alpha v4
Added settings page Added EPG functionality
This commit is contained in:
parent
3a15cf6b7f
commit
d89bf35c0d
17 changed files with 1081 additions and 212 deletions
|
|
@ -27,9 +27,6 @@ class ChannelForm(forms.ModelForm):
|
|||
'channel_number',
|
||||
'channel_name',
|
||||
'channel_group',
|
||||
'is_active',
|
||||
'is_looping',
|
||||
'shuffle_mode',
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -47,6 +44,5 @@ class StreamForm(forms.ModelForm):
|
|||
'tvg_id',
|
||||
'local_file',
|
||||
'is_transcoded',
|
||||
'ffmpeg_preset',
|
||||
'group_name',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from django.contrib import admin
|
||||
from .models import EPGSource, Program
|
||||
from .models import EPGSource, ProgramData
|
||||
|
||||
@admin.register(EPGSource)
|
||||
class EPGSourceAdmin(admin.ModelAdmin):
|
||||
|
|
@ -7,13 +7,13 @@ class EPGSourceAdmin(admin.ModelAdmin):
|
|||
list_filter = ['source_type', 'is_active']
|
||||
search_fields = ['name']
|
||||
|
||||
@admin.register(Program)
|
||||
@admin.register(ProgramData)
|
||||
class ProgramAdmin(admin.ModelAdmin):
|
||||
list_display = ['title', 'get_channel_tvg_id', 'start_time', 'end_time']
|
||||
list_filter = ['channel']
|
||||
search_fields = ['title', 'channel__channel_name']
|
||||
list_filter = ['epg__channel'] # updated here
|
||||
search_fields = ['title', 'epg__channel__channel_name'] # updated here
|
||||
|
||||
def get_channel_tvg_id(self, obj):
|
||||
return obj.channel.tvg_id if obj.channel else ''
|
||||
return obj.epg.channel.tvg_id if obj.epg and obj.epg.channel else ''
|
||||
get_channel_tvg_id.short_description = 'Channel TVG ID'
|
||||
get_channel_tvg_id.admin_order_field = 'channel__tvg_id'
|
||||
get_channel_tvg_id.admin_order_field = 'epg__channel__tvg_id'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from rest_framework import generics, status, viewsets
|
||||
import logging
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
|
@ -6,46 +7,64 @@ from drf_yasg.utils import swagger_auto_schema
|
|||
from drf_yasg import openapi
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from .models import Program, EPGSource
|
||||
from .serializers import ProgramSerializer, EPGSourceSerializer
|
||||
from .models import EPGSource, ProgramData # Updated: use ProgramData instead of Program
|
||||
from .serializers import ProgramDataSerializer, EPGSourceSerializer # Updated serializer
|
||||
from .tasks import refresh_epg_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 🔹 1) EPG Source API (CRUD)
|
||||
# ─────────────────────────────
|
||||
# 1) EPG Source API (CRUD)
|
||||
# ─────────────────────────────
|
||||
class EPGSourceViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for EPG sources"""
|
||||
queryset = EPGSource.objects.all()
|
||||
serializer_class = EPGSourceSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
logger.debug("Listing all EPG sources.")
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
# 🔹 2) Program API (CRUD)
|
||||
# ─────────────────────────────
|
||||
# 2) Program API (CRUD)
|
||||
# ─────────────────────────────
|
||||
class ProgramViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for EPG programs"""
|
||||
queryset = Program.objects.all()
|
||||
serializer_class = ProgramSerializer
|
||||
queryset = ProgramData.objects.all() # Updated to ProgramData
|
||||
serializer_class = ProgramDataSerializer # Updated serializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
logger.debug("Listing all EPG programs.")
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
# 🔹 3) EPG Grid View: Shows programs airing within the next 12 hours
|
||||
# ─────────────────────────────
|
||||
# 3) EPG Grid View
|
||||
# ─────────────────────────────
|
||||
class EPGGridAPIView(APIView):
|
||||
"""Returns all programs airing in the next 12 hours"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Retrieve upcoming EPG programs within the next 12 hours",
|
||||
responses={200: ProgramSerializer(many=True)}
|
||||
responses={200: ProgramDataSerializer(many=True)}
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
now = timezone.now()
|
||||
twelve_hours_later = now + timedelta(hours=12)
|
||||
programs = Program.objects.select_related('channel').filter(
|
||||
logger.debug(f"EPGGridAPIView: Querying programs between {now} and {twelve_hours_later}.")
|
||||
# Use select_related to prefetch EPGData and Channel data
|
||||
programs = ProgramData.objects.select_related('epg__channel').filter(
|
||||
start_time__gte=now, start_time__lte=twelve_hours_later
|
||||
)
|
||||
serializer = ProgramSerializer(programs, many=True)
|
||||
count = programs.count()
|
||||
logger.debug(f"EPGGridAPIView: Found {count} program(s).")
|
||||
serializer = ProgramDataSerializer(programs, many=True)
|
||||
return Response({'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
# 🔹 4) EPG Import View: Triggers an import of EPG data
|
||||
# ─────────────────────────────
|
||||
# 4) EPG Import View
|
||||
# ─────────────────────────────
|
||||
class EPGImportAPIView(APIView):
|
||||
"""Triggers an EPG data refresh"""
|
||||
|
||||
|
|
@ -54,5 +73,7 @@ class EPGImportAPIView(APIView):
|
|||
responses={202: "EPG data import initiated"}
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
logger.info("EPGImportAPIView: Received request to import EPG data.")
|
||||
refresh_epg_data.delay() # Trigger Celery task
|
||||
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
|
||||
return Response({'success': True, 'message': 'EPG data import initiated.'}, status=status.HTTP_202_ACCEPTED)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import models
|
|||
from django.utils import timezone
|
||||
from apps.channels.models import Channel
|
||||
|
||||
|
||||
class EPGSource(models.Model):
|
||||
SOURCE_TYPE_CHOICES = [
|
||||
('xmltv', 'XMLTV URL'),
|
||||
|
|
@ -17,12 +16,25 @@ class EPGSource(models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Program(models.Model):
|
||||
channel = models.ForeignKey('channels.Channel', on_delete=models.CASCADE, related_name="programs")
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
class EPGData(models.Model):
|
||||
"""
|
||||
Stores EPG data for a specific channel.
|
||||
"""
|
||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE, related_name="epg_data")
|
||||
channel_name = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return f"EPG Data for {self.channel_name}"
|
||||
|
||||
|
||||
class ProgramData(models.Model):
|
||||
epg = models.ForeignKey(EPGData, on_delete=models.CASCADE, related_name="programs")
|
||||
start_time = models.DateTimeField()
|
||||
end_time = models.DateTimeField()
|
||||
title = models.CharField(max_length=255)
|
||||
sub_title = models.CharField(max_length=255, blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.start_time} - {self.end_time})"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Program, EPGSource
|
||||
from .models import EPGSource, EPGData, ProgramData
|
||||
from apps.channels.models import Channel
|
||||
|
||||
class EPGSourceSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -7,13 +7,18 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
model = EPGSource
|
||||
fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active']
|
||||
|
||||
class ProgramDataSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProgramData
|
||||
fields = ['id', 'start_time', 'end_time', 'title', 'sub_title', 'description']
|
||||
|
||||
class ProgramSerializer(serializers.ModelSerializer):
|
||||
class EPGDataSerializer(serializers.ModelSerializer):
|
||||
programs = ProgramDataSerializer(many=True, read_only=True)
|
||||
channel = serializers.SerializerMethodField()
|
||||
|
||||
def get_channel(self, obj):
|
||||
return {"id": obj.channel.id, "name": obj.channel.name} if obj.channel else None
|
||||
return {"id": obj.channel.id, "name": obj.channel.channel_name} if obj.channel else None
|
||||
|
||||
class Meta:
|
||||
model = Program
|
||||
fields = ['id', 'channel', 'title', 'description', 'start_time', 'end_time']
|
||||
model = EPGData
|
||||
fields = ['id', 'channel', 'channel_name', 'programs']
|
||||
|
|
|
|||
|
|
@ -1,119 +1,180 @@
|
|||
import logging
|
||||
from celery import shared_task
|
||||
from .models import EPGSource, Program
|
||||
from .models import EPGSource, EPGData, ProgramData
|
||||
from apps.channels.models import Channel
|
||||
from django.utils import timezone
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@shared_task
|
||||
def refresh_epg_data():
|
||||
logger.info("Starting refresh_epg_data task.")
|
||||
active_sources = EPGSource.objects.filter(is_active=True)
|
||||
logger.debug(f"Found {active_sources.count()} active EPGSource(s).")
|
||||
for source in active_sources:
|
||||
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
|
||||
if source.source_type == 'xmltv':
|
||||
fetch_xmltv(source)
|
||||
elif source.source_type == 'schedules_direct':
|
||||
fetch_schedules_direct(source)
|
||||
logger.info("Finished refresh_epg_data task.")
|
||||
return "EPG data refreshed."
|
||||
|
||||
def fetch_xmltv(source):
|
||||
logger.info(f"Fetching XMLTV data from source: {source.name}")
|
||||
try:
|
||||
response = requests.get(source.url, timeout=30)
|
||||
response.raise_for_status()
|
||||
logger.debug("XMLTV data fetched successfully.")
|
||||
root = ET.fromstring(response.content)
|
||||
logger.debug("Parsed XMLTV XML content.")
|
||||
|
||||
with transaction.atomic():
|
||||
for programme in root.findall('programme'):
|
||||
start_time = parse_xmltv_time(programme.get('start'))
|
||||
stop_time = parse_xmltv_time(programme.get('stop'))
|
||||
channel_tvg_id = programme.get('channel')
|
||||
# Group programmes by channel tvg_id
|
||||
programmes_by_channel = {}
|
||||
for programme in root.findall('programme'):
|
||||
start_time = parse_xmltv_time(programme.get('start'))
|
||||
stop_time = parse_xmltv_time(programme.get('stop'))
|
||||
channel_tvg_id = programme.get('channel')
|
||||
title = programme.findtext('title', default='No Title')
|
||||
desc = programme.findtext('desc', default='')
|
||||
|
||||
title = programme.findtext('title', default='No Title')
|
||||
desc = programme.findtext('desc', default='')
|
||||
programmes_by_channel.setdefault(channel_tvg_id, []).append({
|
||||
'start_time': start_time,
|
||||
'end_time': stop_time,
|
||||
'title': title,
|
||||
'description': desc,
|
||||
})
|
||||
|
||||
# Process each channel group
|
||||
for tvg_id, programmes in programmes_by_channel.items():
|
||||
try:
|
||||
channel = Channel.objects.get(tvg_id=tvg_id)
|
||||
logger.debug(f"Found Channel: {channel}")
|
||||
except Channel.DoesNotExist:
|
||||
logger.warning(f"No channel found for tvg_id '{tvg_id}'. Skipping programmes.")
|
||||
continue
|
||||
|
||||
# Find or create the channel
|
||||
try:
|
||||
channel = Channel.objects.get(tvg_id=channel_tvg_id)
|
||||
except Channel.DoesNotExist:
|
||||
# Optionally, skip programs for unknown channels
|
||||
continue
|
||||
|
||||
# Create or update the program
|
||||
Program.objects.update_or_create(
|
||||
channel=channel,
|
||||
title=title,
|
||||
start_time=start_time,
|
||||
end_time=stop_time,
|
||||
defaults={'description': desc}
|
||||
)
|
||||
# Get or create the EPGData record for the channel
|
||||
epg_data, created = EPGData.objects.get_or_create(
|
||||
channel=channel,
|
||||
defaults={'channel_name': channel.channel_name}
|
||||
)
|
||||
if not created and epg_data.channel_name != channel.channel_name:
|
||||
epg_data.channel_name = channel.channel_name
|
||||
epg_data.save(update_fields=['channel_name'])
|
||||
|
||||
logger.info(f"Processing {len(programmes)} programme(s) for channel '{channel.channel_name}'.")
|
||||
# For each programme, update or create a ProgramData record
|
||||
with transaction.atomic():
|
||||
for prog in programmes:
|
||||
obj, created = ProgramData.objects.update_or_create(
|
||||
epg=epg_data,
|
||||
start_time=prog['start_time'],
|
||||
title=prog['title'],
|
||||
defaults={
|
||||
'end_time': prog['end_time'],
|
||||
'description': prog['description'],
|
||||
'sub_title': ''
|
||||
}
|
||||
)
|
||||
if created:
|
||||
logger.info(f"Created ProgramData '{prog['title']}' for channel '{channel.channel_name}'.")
|
||||
else:
|
||||
logger.info(f"Updated ProgramData '{prog['title']}' for channel '{channel.channel_name}'.")
|
||||
except Exception as e:
|
||||
# Log the error appropriately
|
||||
print(f"Error fetching XMLTV from {source.name}: {e}")
|
||||
logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True)
|
||||
|
||||
def fetch_schedules_direct(source):
|
||||
logger.info(f"Fetching Schedules Direct data from source: {source.name}")
|
||||
try:
|
||||
# need to add a setting for api url.
|
||||
|
||||
# NOTE: You need to provide the correct api_url for Schedules Direct.
|
||||
api_url = ''
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {source.api_key}',
|
||||
}
|
||||
|
||||
# Fetch subscriptions (channels)
|
||||
logger.debug(f"Requesting subscriptions from Schedules Direct using URL: {api_url}")
|
||||
response = requests.get(api_url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
subscriptions = response.json()
|
||||
logger.debug(f"Fetched subscriptions: {subscriptions}")
|
||||
|
||||
# Fetch schedules for each subscription
|
||||
for sub in subscriptions:
|
||||
channel_tvg_id = sub.get('stationID')
|
||||
# Fetch schedules
|
||||
# Need to add schedules direct url
|
||||
logger.debug(f"Processing subscription for tvg_id: {channel_tvg_id}")
|
||||
schedules_url = f"/schedules/{channel_tvg_id}"
|
||||
logger.debug(f"Requesting schedules from URL: {schedules_url}")
|
||||
sched_response = requests.get(schedules_url, headers=headers, timeout=30)
|
||||
sched_response.raise_for_status()
|
||||
schedules = sched_response.json()
|
||||
logger.debug(f"Fetched schedules: {schedules}")
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
channel = Channel.objects.get(tvg_id=channel_tvg_id)
|
||||
except Channel.DoesNotExist:
|
||||
# skip programs for unknown channels
|
||||
continue
|
||||
try:
|
||||
channel = Channel.objects.get(tvg_id=channel_tvg_id)
|
||||
logger.debug(f"Found Channel: {channel}")
|
||||
except Channel.DoesNotExist:
|
||||
logger.warning(f"No channel found for tvg_id '{channel_tvg_id}'. Skipping subscription.")
|
||||
continue
|
||||
|
||||
for sched in schedules.get('schedules', []):
|
||||
title = sched.get('title', 'No Title')
|
||||
desc = sched.get('description', '')
|
||||
start_time = parse_schedules_direct_time(sched.get('startTime'))
|
||||
end_time = parse_schedules_direct_time(sched.get('endTime'))
|
||||
|
||||
Program.objects.update_or_create(
|
||||
channel=channel,
|
||||
title=title,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
defaults={'description': desc}
|
||||
)
|
||||
# Get or create the EPGData record for the channel
|
||||
epg_data, created = EPGData.objects.get_or_create(
|
||||
channel=channel,
|
||||
defaults={'channel_name': channel.channel_name}
|
||||
)
|
||||
if not created and epg_data.channel_name != channel.channel_name:
|
||||
epg_data.channel_name = channel.channel_name
|
||||
epg_data.save(update_fields=['channel_name'])
|
||||
|
||||
for sched in schedules.get('schedules', []):
|
||||
title = sched.get('title', 'No Title')
|
||||
desc = sched.get('description', '')
|
||||
start_time = parse_schedules_direct_time(sched.get('startTime'))
|
||||
end_time = parse_schedules_direct_time(sched.get('endTime'))
|
||||
obj, created = ProgramData.objects.update_or_create(
|
||||
epg=epg_data,
|
||||
start_time=start_time,
|
||||
title=title,
|
||||
defaults={
|
||||
'end_time': end_time,
|
||||
'description': desc,
|
||||
'sub_title': ''
|
||||
}
|
||||
)
|
||||
if created:
|
||||
logger.info(f"Created ProgramData '{title}' for channel '{channel.channel_name}'.")
|
||||
else:
|
||||
logger.info(f"Updated ProgramData '{title}' for channel '{channel.channel_name}'.")
|
||||
except Exception as e:
|
||||
# Log the error appropriately
|
||||
print(f"Error fetching Schedules Direct data from {source.name}: {e}")
|
||||
logger.error(f"Error fetching Schedules Direct data from {source.name}: {e}", exc_info=True)
|
||||
|
||||
def parse_xmltv_time(time_str):
|
||||
# XMLTV time format: '20250130120000 +0000'
|
||||
dt = datetime.strptime(time_str[:14], '%Y%m%d%H%M%S')
|
||||
tz_sign = time_str[15]
|
||||
tz_hours = int(time_str[16:18])
|
||||
tz_minutes = int(time_str[18:20])
|
||||
if tz_sign == '+':
|
||||
dt = dt - timedelta(hours=tz_hours, minutes=tz_minutes)
|
||||
elif tz_sign == '-':
|
||||
dt = dt + timedelta(hours=tz_hours, minutes=tz_minutes)
|
||||
return timezone.make_aware(dt, timezone=timezone.utc)
|
||||
try:
|
||||
dt_obj = datetime.strptime(time_str[:14], '%Y%m%d%H%M%S')
|
||||
tz_sign = time_str[15]
|
||||
tz_hours = int(time_str[16:18])
|
||||
tz_minutes = int(time_str[18:20])
|
||||
if tz_sign == '+':
|
||||
dt_obj = dt_obj - timedelta(hours=tz_hours, minutes=tz_minutes)
|
||||
elif tz_sign == '-':
|
||||
dt_obj = dt_obj + timedelta(hours=tz_hours, minutes=tz_minutes)
|
||||
# Make the datetime aware with UTC using the imported dt_timezone
|
||||
aware_dt = timezone.make_aware(dt_obj, timezone=dt_timezone.utc)
|
||||
logger.debug(f"Parsed XMLTV time '{time_str}' to {aware_dt}")
|
||||
return aware_dt
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing XMLTV time '{time_str}': {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def parse_schedules_direct_time(time_str):
|
||||
# Schedules Direct time format: ISO 8601, e.g., '2025-01-30T12:00:00Z'
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ')
|
||||
return timezone.make_aware(dt, timezone=timezone.utc)
|
||||
try:
|
||||
dt_obj = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ')
|
||||
aware_dt = timezone.make_aware(dt_obj, timezone=dt_timezone.utc)
|
||||
logger.debug(f"Parsed Schedules Direct time '{time_str}' to {aware_dt}")
|
||||
return aware_dt
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing Schedules Direct time '{time_str}': {e}", exc_info=True)
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from django.urls import path
|
||||
from .views import EPGDashboardView
|
||||
from .views import EPGDashboardView, epg_view
|
||||
|
||||
app_name = 'epg_dashboard'
|
||||
|
||||
urlpatterns = [
|
||||
path('dashboard/', EPGDashboardView.as_view(), name='epg_dashboard'),
|
||||
path('guide/', epg_view, name='epg_guide'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,50 @@ from django.views import View
|
|||
from django.shortcuts import render
|
||||
from django.http import JsonResponse
|
||||
from rest_framework.parsers import JSONParser
|
||||
from .models import EPGSource
|
||||
from .models import EPGSource, ProgramData # Updated: import ProgramData instead of Program
|
||||
from .serializers import EPGSourceSerializer
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
def epg_view(request):
|
||||
"""
|
||||
Renders the TV guide using programmes from the next 12 hours,
|
||||
grouped by channel (via EPGData).
|
||||
"""
|
||||
now = timezone.now()
|
||||
end_time = now + timedelta(hours=12)
|
||||
print(f"[EPG VIEW] Now: {now} | End Time: {end_time}")
|
||||
|
||||
# Query ProgramData within the time range
|
||||
programmes = ProgramData.objects.filter(
|
||||
start_time__gte=now,
|
||||
start_time__lte=end_time
|
||||
).order_by('start_time')
|
||||
print(f"[EPG VIEW] Found {programmes.count()} programme(s) between now and end_time.")
|
||||
|
||||
# Group programmes by channel (retrieved via the EPGData parent)
|
||||
channels = {}
|
||||
for prog in programmes:
|
||||
# Assume that the EPGData instance (prog.epg) has a link to a Channel.
|
||||
channel = prog.epg.channel if prog.epg and prog.epg.channel else None
|
||||
if not channel:
|
||||
continue
|
||||
channels.setdefault(channel, []).append(prog)
|
||||
|
||||
if not channels:
|
||||
print("[EPG VIEW] No channels with programmes found.")
|
||||
else:
|
||||
for channel, progs in channels.items():
|
||||
print(f"[EPG VIEW] Channel: {channel} has {len(progs)} programme(s).")
|
||||
|
||||
context = {
|
||||
'channels': channels,
|
||||
'now': now,
|
||||
'end_time': end_time,
|
||||
}
|
||||
return render(request, 'epg/tvguide.html', context)
|
||||
|
||||
|
||||
class EPGDashboardView(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from django.urls import path, include
|
||||
from .views import generate_m3u
|
||||
from .views import generate_m3u, generate_epg
|
||||
from core.views import stream_view
|
||||
|
||||
|
||||
|
|
@ -7,5 +7,6 @@ app_name = 'output'
|
|||
|
||||
urlpatterns = [
|
||||
path('m3u/', generate_m3u, name='generate_m3u'),
|
||||
path('epg/', generate_epg, name='generate_epg'),
|
||||
path('stream/<int:stream_id>/', stream_view, name='stream'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from apps.channels.models import Channel
|
||||
from datetime import timedelta
|
||||
from apps.epg.models import ProgramData
|
||||
from apps.channels.models import Channel
|
||||
from django.utils import timezone
|
||||
|
||||
def generate_m3u(request):
|
||||
"""
|
||||
|
|
@ -28,3 +32,53 @@ def generate_m3u(request):
|
|||
response = HttpResponse(m3u_content, content_type="application/x-mpegURL")
|
||||
response['Content-Disposition'] = 'attachment; filename="channels.m3u"'
|
||||
return response
|
||||
|
||||
def generate_epg(request):
|
||||
"""
|
||||
Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models.
|
||||
Only channels that have EPG programmes (via EPGData) are included.
|
||||
"""
|
||||
now = timezone.now()
|
||||
end_time = now + timedelta(hours=24)
|
||||
|
||||
# Query ProgramData objects in the next 24 hours
|
||||
programs = ProgramData.objects.select_related('epg__channel').filter(
|
||||
start_time__gte=now, start_time__lte=end_time
|
||||
).order_by('start_time')
|
||||
|
||||
# Group programmes by their channel (via EPGData)
|
||||
channels_programs = {}
|
||||
for prog in programs:
|
||||
if prog.epg and prog.epg.channel:
|
||||
channel = prog.epg.channel
|
||||
channels_programs.setdefault(channel, []).append(prog)
|
||||
|
||||
xml_lines = []
|
||||
xml_lines.append('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
xml_lines.append('<tv generator-info-name="Dispatcharr" generator-info-url="https://example.com">')
|
||||
|
||||
# Output channel definitions for channels that have programmes
|
||||
for channel in channels_programs.keys():
|
||||
xml_lines.append(f' <channel id="{channel.id}">')
|
||||
xml_lines.append(f' <display-name>{channel.channel_name}</display-name>')
|
||||
if channel.logo_url:
|
||||
xml_lines.append(f' <icon src="{channel.logo_url}" />')
|
||||
xml_lines.append(' </channel>')
|
||||
|
||||
# Output programme entries
|
||||
for channel, progs in channels_programs.items():
|
||||
for prog in progs:
|
||||
start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z")
|
||||
stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z")
|
||||
xml_lines.append(f' <programme start="{start_str}" stop="{stop_str}" channel="{channel.id}">')
|
||||
xml_lines.append(f' <title>{prog.title}</title>')
|
||||
xml_lines.append(f' <desc>{prog.description}</desc>')
|
||||
xml_lines.append(' </programme>')
|
||||
|
||||
xml_lines.append('</tv>')
|
||||
xml_content = "\n".join(xml_lines)
|
||||
|
||||
response = HttpResponse(xml_content, content_type="application/xml")
|
||||
response['Content-Disposition'] = 'attachment; filename="epg.xml"'
|
||||
return response
|
||||
|
||||
|
|
|
|||
6
core/urls.py
Normal file
6
core/urls.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.urls import path
|
||||
from .views import settings_view
|
||||
|
||||
urlpatterns = [
|
||||
path('', settings_view, name='settings'),
|
||||
]
|
||||
|
|
@ -6,6 +6,7 @@ import logging
|
|||
from django.conf import settings
|
||||
from django.http import StreamingHttpResponse, HttpResponseServerError
|
||||
from django.db.models import F
|
||||
from django.shortcuts import render
|
||||
|
||||
from apps.channels.models import Channel, Stream
|
||||
from core.models import StreamProfile
|
||||
|
|
@ -14,6 +15,13 @@ from core.models import StreamProfile
|
|||
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def settings_view(request):
|
||||
"""
|
||||
Renders the settings page.
|
||||
"""
|
||||
return render(request, 'settings.html')
|
||||
|
||||
|
||||
def stream_view(request, stream_id):
|
||||
"""
|
||||
Streams the first available stream for the given channel.
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ urlpatterns = [
|
|||
path('m3u/', include(('apps.m3u.urls', 'm3u'), namespace='m3u')),
|
||||
path('epg/', include(('apps.epg.urls', 'epg'), namespace='epg')),
|
||||
path('channels/', include(('apps.channels.urls', 'channels'), namespace='channels')),
|
||||
#path('settings/', include(('apps.settings.urls', 'settings'), namespace='settings')),
|
||||
path('settings/', include(('core.urls', 'settings'), namespace='settings')),
|
||||
#path('backup/', include(('apps.backup.urls', 'backup'), namespace='backup')),
|
||||
path('dashboard/', include(('apps.dashboard.urls', 'dashboard'), namespace='dashboard')),
|
||||
path('output/', include('apps.output.urls', namespace='output')),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ services:
|
|||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- POSTGRES_HOST=localhost
|
||||
- POSTGRES_HOST=db
|
||||
- POSTGRES_DB=dispatcharr
|
||||
- POSTGRES_USER=dispatch
|
||||
- POSTGRES_PASSWORD=secret
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<!-- Stream Details -->
|
||||
<div class="mb-3">
|
||||
<label for="newStreamNameField" class="form-label">Stream Name</label>
|
||||
<input type="text" class="form-control" id="newStreamNameField" name="stream_name" required>
|
||||
<input type="text" class="form-control" id="newStreamNameField" name="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newStreamGroupField" class="form-label">Stream Group</label>
|
||||
|
|
|
|||
34
templates/epg/tvguide.html
Executable file
34
templates/epg/tvguide.html
Executable file
|
|
@ -0,0 +1,34 @@
|
|||
{# templates/output/epg_tvguide.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}TV Guide{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>TV Guide</h1>
|
||||
<p>Showing programs from {{ now|date:"H:i" }} to {{ end_time|date:"H:i" }}</p>
|
||||
|
||||
{% for channel, programs in channels.items %}
|
||||
<h2>{{ channel.channel_name }}</h2>
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Start Time</th>
|
||||
<th>End Time</th>
|
||||
<th>Program Title</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for program in programs %}
|
||||
<tr>
|
||||
<td>{{ program.start_time|date:"H:i" }}</td>
|
||||
<td>{{ program.end_time|date:"H:i" }}</td>
|
||||
<td>{{ program.title }}</td>
|
||||
<td>{{ program.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% empty %}
|
||||
<p>No programs scheduled in this time frame.</p>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,68 +1,334 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}M3U Management - Dispatcharr{% endblock %}
|
||||
{% block page_header %}M3U Management{% endblock %}
|
||||
{% block title %}Settings - Dispatcharr{% endblock %}
|
||||
{% block page_header %}Settings{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">M3U Management</li>
|
||||
<li class="breadcrumb-item"><a href="/core/dashboard/">Home</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Settings</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">M3U Accounts</h3>
|
||||
<button id="addM3UBtn" class="btn btn-primary float-end">
|
||||
<i class="bi bi-plus"></i> Add M3U Account
|
||||
</button>
|
||||
<h3 class="card-title">Settings</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table id="m3uTable" class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Server URL</th>
|
||||
<th>Uploaded File</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Nav Tabs -->
|
||||
<ul class="nav nav-tabs" id="settingsTab" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="stream-profiles-tab" data-bs-toggle="tab" href="#stream-profiles"
|
||||
role="tab" aria-controls="stream-profiles" aria-selected="true">
|
||||
Stream Profiles
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="user-agents-tab" data-bs-toggle="tab" href="#user-agents" role="tab"
|
||||
aria-controls="user-agents" aria-selected="false">
|
||||
User Agents
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="logo-caching-tab" data-bs-toggle="tab" href="#logo-caching" role="tab"
|
||||
aria-controls="logo-caching" aria-selected="false">
|
||||
Logo Caching
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="backup-restore-tab" data-bs-toggle="tab" href="#backup-restore" role="tab"
|
||||
aria-controls="backup-restore" aria-selected="false">
|
||||
Backup/Restore
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="hdhr-tab" data-bs-toggle="tab" href="#hdhr" role="tab"
|
||||
aria-controls="hdhr" aria-selected="false">
|
||||
HDHR Emulation
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="user-management-tab" data-bs-toggle="tab" href="#user-management"
|
||||
role="tab" aria-controls="user-management" aria-selected="false">
|
||||
Users & Groups
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- M3U Modal -->
|
||||
<div class="modal fade" id="m3uModal" tabindex="-1" aria-labelledby="m3uModalLabel" aria-hidden="true">
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content mt-3" id="settingsTabContent">
|
||||
|
||||
<!-- STREAM PROFILES -->
|
||||
<div class="tab-pane fade show active" id="stream-profiles" role="tabpanel"
|
||||
aria-labelledby="stream-profiles-tab">
|
||||
<button id="addStreamProfileBtn" class="btn btn-primary mb-2">
|
||||
<i class="bi bi-plus"></i> Add Stream Profile
|
||||
</button>
|
||||
<div class="table-responsive">
|
||||
<table id="streamProfilesTable" class="table table-striped nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Profile Name</th>
|
||||
<th>Command</th>
|
||||
<th>Parameters</th>
|
||||
<th>Active</th>
|
||||
<th>User Agent</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USER AGENTS -->
|
||||
<div class="tab-pane fade" id="user-agents" role="tabpanel" aria-labelledby="user-agents-tab">
|
||||
<button id="addUserAgentBtn" class="btn btn-primary mb-2">
|
||||
<i class="bi bi-plus"></i> Add User Agent
|
||||
</button>
|
||||
<div class="table-responsive">
|
||||
<table id="userAgentsTable" class="table table-striped nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User Agent Name</th>
|
||||
<th>User Agent</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOGO CACHING -->
|
||||
<div class="tab-pane fade" id="logo-caching" role="tabpanel" aria-labelledby="logo-caching-tab">
|
||||
<form id="logoCachingForm" class="mt-3">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="cacheLogosToggle" name="cache_logos">
|
||||
<label class="form-check-label" for="cacheLogosToggle">
|
||||
Cache Channel Logos Locally
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save Logo Caching Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- BACKUP / RESTORE -->
|
||||
<div class="tab-pane fade" id="backup-restore" role="tabpanel" aria-labelledby="backup-restore-tab">
|
||||
<div class="mt-3">
|
||||
<button id="createBackupBtn" class="btn btn-success mb-2">
|
||||
<i class="bi bi-download"></i> Create Backup
|
||||
</button>
|
||||
<button id="restoreBackupBtn" class="btn btn-warning mb-2">
|
||||
<i class="bi bi-upload"></i> Restore Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HDHR EMULATION -->
|
||||
<div class="tab-pane fade" id="hdhr" role="tabpanel" aria-labelledby="hdhr-tab">
|
||||
<form id="hdhrEmulationForm" class="mt-3">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="hdhrEmulationToggle" name="hdhr_emulation">
|
||||
<label class="form-check-label" for="hdhrEmulationToggle">Enable HDHR Emulation</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save HDHR Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- USERS & GROUPS -->
|
||||
<div class="tab-pane fade" id="user-management" role="tabpanel" aria-labelledby="user-management-tab">
|
||||
<div class="row mt-3">
|
||||
<!-- USERS -->
|
||||
<div class="col-md-6">
|
||||
<h5>Users</h5>
|
||||
<button id="addUserBtn" class="btn btn-primary mb-2">
|
||||
<i class="bi bi-plus"></i> Add User
|
||||
</button>
|
||||
<div class="table-responsive">
|
||||
<table id="usersTable" class="table table-striped nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Groups</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GROUPS -->
|
||||
<div class="col-md-6">
|
||||
<h5>Groups</h5>
|
||||
<button id="addGroupBtn" class="btn btn-primary mb-2">
|
||||
<i class="bi bi-plus"></i> Add Group
|
||||
</button>
|
||||
<div class="table-responsive">
|
||||
<table id="groupsTable" class="table table-striped nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Permissions</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end row -->
|
||||
</div><!-- end user-management tab content -->
|
||||
</div><!-- end tab-content -->
|
||||
</div><!-- end card-body -->
|
||||
</div><!-- end card -->
|
||||
|
||||
<!-- ============== MODALS ============== -->
|
||||
|
||||
<!-- STREAM PROFILE MODAL -->
|
||||
<div class="modal fade" id="streamProfileModal" tabindex="-1" aria-labelledby="streamProfileModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<form id="m3uForm" enctype="multipart/form-data">
|
||||
<form id="streamProfileForm" enctype="multipart/form-data">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="m3uModalLabel">M3U Account</h5>
|
||||
<h5 class="modal-title" id="streamProfileModalLabel">Stream Profile</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="m3uId" name="id">
|
||||
<input type="hidden" id="streamProfileId" name="id">
|
||||
<div class="mb-3">
|
||||
<label for="m3uName" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="m3uName" name="name" required>
|
||||
<label for="profileName" class="form-label">Profile Name</label>
|
||||
<input type="text" class="form-control" id="profileName" name="profile_name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="serverUrl" class="form-label">Server URL</label>
|
||||
<input type="url" class="form-control" id="serverUrl" name="server_url">
|
||||
<label for="command" class="form-label">Command</label>
|
||||
<input type="text" class="form-control" id="command" name="command" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="uploadedFile" class="form-label">Uploaded File</label>
|
||||
<input type="file" class="form-control" id="uploadedFile" name="uploaded_file">
|
||||
<label for="parameters" class="form-label">Parameters</label>
|
||||
<textarea class="form-control" id="parameters" name="parameters" required></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="isActive" class="form-label">Active</label>
|
||||
<select class="form-select" id="isActive" name="is_active">
|
||||
<label for="profileActive" class="form-label">Active</label>
|
||||
<select class="form-select" id="profileActive" name="is_active">
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="profileUserAgent" class="form-label">User Agent (optional)</label>
|
||||
<input type="text" class="form-control" id="profileUserAgent" name="user_agent">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Save Profile</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USER AGENT MODAL -->
|
||||
<div class="modal fade" id="userAgentModal" tabindex="-1" aria-labelledby="userAgentModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<form id="userAgentForm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="userAgentModalLabel">Add User Agent</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="userAgentId" name="id">
|
||||
<div class="mb-3">
|
||||
<label for="userAgentNameField" class="form-label">User Agent Name</label>
|
||||
<input type="text" class="form-control" id="userAgentNameField" name="user_agent_name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userAgentStringField" class="form-label">User Agent String</label>
|
||||
<input type="text" class="form-control" id="userAgentStringField" name="user_agent" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userAgentDescription" class="form-label">Description</label>
|
||||
<input type="text" class="form-control" id="userAgentDescription" name="description">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userAgentActive" class="form-label">Active</label>
|
||||
<select class="form-select" id="userAgentActive" name="is_active">
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Save Account</button>
|
||||
<button type="submit" class="btn btn-primary">Save User Agent</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USER MODAL -->
|
||||
<div class="modal fade" id="userModal" tabindex="-1" aria-labelledby="userModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<form id="userForm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="userModalLabel">Add User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="userId" name="id">
|
||||
<div class="mb-3">
|
||||
<label for="usernameField" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="usernameField" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="emailField" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="emailField" name="email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Save User</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GROUP MODAL -->
|
||||
<div class="modal fade" id="groupModal" tabindex="-1" aria-labelledby="groupModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<form id="groupForm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="groupModalLabel">Add Group</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="groupId" name="id">
|
||||
<div class="mb-3">
|
||||
<label for="groupNameField" class="form-label">Group Name</label>
|
||||
<input type="text" class="form-control" id="groupNameField" name="name" required>
|
||||
</div>
|
||||
<!-- If you want to pick permissions:
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Permissions</label>
|
||||
<select class="form-select" id="groupPermissionsField" name="permissions" multiple>
|
||||
...
|
||||
</select>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Save Group</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,86 +336,448 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- DataTables CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.3.0/css/responsive.dataTables.min.css">
|
||||
|
||||
<!-- DataTables JS / Dependencies -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/responsive/2.3.0/js/dataTables.responsive.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
var m3uTable = new DataTable("#m3uTable", {
|
||||
ajax: "{% url 'api:m3u-account-list' %}",
|
||||
/* CSRF Helper */
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + "=")) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
const csrftoken = getCookie("csrftoken");
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
/* STREAM PROFILES TABLE */
|
||||
const streamProfilesTable = new DataTable("#streamProfilesTable", {
|
||||
ajax: { url: "/api/core/streamprofiles/", dataSrc: "" },
|
||||
responsive: true,
|
||||
columns: [
|
||||
{ data: "id" },
|
||||
{ data: "name" },
|
||||
{ data: "server_url" },
|
||||
{ data: "uploaded_file" },
|
||||
{ data: "is_active", render: function(data){ return data ? "Yes" : "No"; } },
|
||||
{ data: null, render: function(data){
|
||||
return '<button class="btn btn-sm btn-primary edit-m3u" data-id="'+data.id+'">Edit</button> ' +
|
||||
'<button class="btn btn-sm btn-danger delete-m3u" data-id="'+data.id+'">Delete</button>';
|
||||
}
|
||||
{ data: "profile_name" },
|
||||
{ data: "command" },
|
||||
{ data: "parameters" },
|
||||
{
|
||||
data: "is_active",
|
||||
render: data => (data ? "Yes" : "No")
|
||||
},
|
||||
{ data: "user_agent" },
|
||||
{
|
||||
data: null,
|
||||
render: row => `
|
||||
<button class="btn btn-sm btn-primary edit-stream-profile" data-id="${row.id}">Edit</button>
|
||||
<button class="btn btn-sm btn-danger delete-stream-profile" data-id="${row.id}">Delete</button>
|
||||
`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
document.getElementById("addM3UBtn").addEventListener("click", function(){
|
||||
document.getElementById("m3uForm").reset();
|
||||
document.getElementById("m3uId").value = '';
|
||||
document.getElementById("m3uModalLabel").textContent = "Add M3U Account";
|
||||
new bootstrap.Modal(document.getElementById("m3uModal")).show();
|
||||
|
||||
/* ADD STREAM PROFILE */
|
||||
document.getElementById("addStreamProfileBtn").addEventListener("click", () => {
|
||||
document.getElementById("streamProfileForm").reset();
|
||||
document.getElementById("streamProfileId").value = "";
|
||||
document.getElementById("streamProfileModalLabel").textContent = "Add Stream Profile";
|
||||
new bootstrap.Modal(document.getElementById("streamProfileModal")).show();
|
||||
});
|
||||
|
||||
document.querySelector("#m3uTable").addEventListener("click", function(e){
|
||||
if(e.target.classList.contains("edit-m3u")){
|
||||
var m3uId = e.target.getAttribute("data-id");
|
||||
fetch("/api/m3u/accounts/" + m3uId + "/")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
document.getElementById("m3uId").value = data.id;
|
||||
document.getElementById("m3uName").value = data.name;
|
||||
document.getElementById("serverUrl").value = data.server_url;
|
||||
document.getElementById("isActive").value = data.is_active ? "true" : "false";
|
||||
document.getElementById("m3uModalLabel").textContent = "Edit M3U Account";
|
||||
new bootstrap.Modal(document.getElementById("m3uModal")).show();
|
||||
});
|
||||
}
|
||||
if(e.target.classList.contains("delete-m3u")){
|
||||
var m3uId = e.target.getAttribute("data-id");
|
||||
Swal.fire({
|
||||
title: 'Are you sure?',
|
||||
text: "This will delete the M3U Account permanently.",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!'
|
||||
}).then(result => {
|
||||
if(result.isConfirmed){
|
||||
fetch("/api/m3u/accounts/" + m3uId + "/", { method: "DELETE" })
|
||||
.then(response => {
|
||||
if(response.ok){
|
||||
Swal.fire("Deleted!", "M3U Account deleted.", "success");
|
||||
m3uTable.ajax.reload();
|
||||
} else {
|
||||
Swal.fire("Error", "Failed to delete M3U Account.", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("m3uForm").addEventListener("submit", function(e){
|
||||
|
||||
/* SAVE STREAM PROFILE */
|
||||
document.getElementById("streamProfileForm").addEventListener("submit", function(e) {
|
||||
e.preventDefault();
|
||||
var m3uId = document.getElementById("m3uId").value;
|
||||
var formData = new FormData(this);
|
||||
var method = m3uId ? "PUT" : "POST";
|
||||
var url = m3uId ? "/api/m3u/accounts/" + m3uId + "/" : "/api/m3u/accounts/";
|
||||
const idVal = document.getElementById("streamProfileId").value;
|
||||
const url = idVal ? `/api/core/streamprofiles/${idVal}/` : "/api/core/streamprofiles/";
|
||||
const method = idVal ? "PUT" : "POST";
|
||||
|
||||
const formData = new FormData(this);
|
||||
fetch(url, {
|
||||
method: method,
|
||||
body: formData
|
||||
}).then(response => {
|
||||
if(response.ok){
|
||||
bootstrap.Modal.getInstance(document.getElementById("m3uModal")).hide();
|
||||
Swal.fire("Success", "M3U Account saved!", "success");
|
||||
m3uTable.ajax.reload();
|
||||
} else {
|
||||
Swal.fire("Error", "Failed to save M3U Account.", "error");
|
||||
body: formData,
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(!resp.ok) throw new Error("Failed to save Stream Profile");
|
||||
return resp.json();
|
||||
})
|
||||
.then(() => {
|
||||
Swal.fire("Success", "Stream Profile saved.", "success");
|
||||
streamProfilesTable.ajax.reload();
|
||||
bootstrap.Modal.getInstance(document.getElementById("streamProfileModal")).hide();
|
||||
})
|
||||
.catch(err => {
|
||||
Swal.fire("Error", err.message, "error");
|
||||
});
|
||||
});
|
||||
|
||||
/* EDIT / DELETE STREAM PROFILE */
|
||||
$("#streamProfilesTable").on("click", ".edit-stream-profile", function(){
|
||||
const rowData = streamProfilesTable.row($(this).closest("tr")).data();
|
||||
document.getElementById("streamProfileModalLabel").textContent = "Edit Stream Profile";
|
||||
document.getElementById("streamProfileId").value = rowData.id;
|
||||
document.getElementById("profileName").value = rowData.profile_name;
|
||||
document.getElementById("command").value = rowData.command;
|
||||
document.getElementById("parameters").value = rowData.parameters;
|
||||
document.getElementById("profileActive").value = rowData.is_active ? "true" : "false";
|
||||
document.getElementById("profileUserAgent").value = rowData.user_agent || "";
|
||||
new bootstrap.Modal(document.getElementById("streamProfileModal")).show();
|
||||
});
|
||||
$("#streamProfilesTable").on("click", ".delete-stream-profile", function(){
|
||||
const rowData = streamProfilesTable.row($(this).closest("tr")).data();
|
||||
Swal.fire({
|
||||
title: "Delete this Stream Profile?",
|
||||
text: `Are you sure you want to delete "${rowData.profile_name}"?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, delete it!"
|
||||
}).then(result => {
|
||||
if(result.isConfirmed) {
|
||||
fetch(`/api/core/streamprofiles/${rowData.id}/`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(!resp.ok) throw new Error("Failed to delete profile");
|
||||
Swal.fire("Deleted!", "Stream Profile was removed.", "success");
|
||||
streamProfilesTable.ajax.reload();
|
||||
})
|
||||
.catch(err => Swal.fire("Error", err.message, "error"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/* USER AGENTS TABLE */
|
||||
const userAgentsTable = new DataTable("#userAgentsTable", {
|
||||
ajax: { url: "/api/core/useragents/", dataSrc: "" },
|
||||
responsive: true,
|
||||
columns: [
|
||||
{ data: "id" },
|
||||
{ data: "user_agent_name" },
|
||||
{ data: "user_agent" },
|
||||
{ data: "description" },
|
||||
{
|
||||
data: "is_active",
|
||||
render: data => (data ? "Yes" : "No")
|
||||
},
|
||||
{
|
||||
data: null,
|
||||
render: row => `
|
||||
<button class="btn btn-sm btn-primary edit-user-agent" data-id="${row.id}">Edit</button>
|
||||
<button class="btn btn-sm btn-danger delete-user-agent" data-id="${row.id}">Delete</button>
|
||||
`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
/* ADD USER AGENT */
|
||||
document.getElementById("addUserAgentBtn").addEventListener("click", () => {
|
||||
document.getElementById("userAgentForm").reset();
|
||||
document.getElementById("userAgentModalLabel").textContent = "Add User Agent";
|
||||
document.getElementById("userAgentId").value = "";
|
||||
new bootstrap.Modal(document.getElementById("userAgentModal")).show();
|
||||
});
|
||||
|
||||
/* SAVE USER AGENT */
|
||||
document.getElementById("userAgentForm").addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
const idVal = document.getElementById("userAgentId").value;
|
||||
const url = idVal ? `/api/core/useragents/${idVal}/` : "/api/core/useragents/";
|
||||
const method = idVal ? "PUT" : "POST";
|
||||
|
||||
const formData = new FormData(this);
|
||||
fetch(url, {
|
||||
method,
|
||||
body: formData,
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(!resp.ok) throw new Error("Failed to save user agent");
|
||||
return resp.json();
|
||||
})
|
||||
.then(() => {
|
||||
Swal.fire("Success", "User Agent saved.", "success");
|
||||
userAgentsTable.ajax.reload();
|
||||
bootstrap.Modal.getInstance(document.getElementById("userAgentModal")).hide();
|
||||
})
|
||||
.catch(err => Swal.fire("Error", err.message, "error"));
|
||||
});
|
||||
|
||||
/* EDIT / DELETE USER AGENT */
|
||||
$("#userAgentsTable").on("click", ".edit-user-agent", function(){
|
||||
const rowData = userAgentsTable.row($(this).closest("tr")).data();
|
||||
document.getElementById("userAgentModalLabel").textContent = "Edit User Agent";
|
||||
document.getElementById("userAgentId").value = rowData.id;
|
||||
document.getElementById("userAgentNameField").value = rowData.user_agent_name || "";
|
||||
document.getElementById("userAgentStringField").value = rowData.user_agent || "";
|
||||
document.getElementById("userAgentDescription").value = rowData.description || "";
|
||||
document.getElementById("userAgentActive").value = rowData.is_active ? "true" : "false";
|
||||
new bootstrap.Modal(document.getElementById("userAgentModal")).show();
|
||||
});
|
||||
$("#userAgentsTable").on("click", ".delete-user-agent", function(){
|
||||
const rowData = userAgentsTable.row($(this).closest("tr")).data();
|
||||
Swal.fire({
|
||||
title: "Delete this User Agent?",
|
||||
text: `Are you sure you want to delete "${rowData.user_agent_name}"?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, delete it!"
|
||||
}).then(result => {
|
||||
if(result.isConfirmed) {
|
||||
fetch(`/api/core/useragents/${rowData.id}/`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(!resp.ok) throw new Error("Failed to delete user agent");
|
||||
Swal.fire("Deleted!", "User Agent was removed.", "success");
|
||||
userAgentsTable.ajax.reload();
|
||||
})
|
||||
.catch(err => Swal.fire("Error", err.message, "error"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/* LOGO CACHING SETTINGS */
|
||||
document.getElementById("logoCachingForm").addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
fetch("/api/core/settings/1/", {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(response => {
|
||||
if(response.ok) Swal.fire("Success", "Logo caching settings saved.", "success");
|
||||
else Swal.fire("Error", "Failed to save logo caching settings.", "error");
|
||||
});
|
||||
});
|
||||
|
||||
/* BACKUP / RESTORE */
|
||||
document.getElementById("createBackupBtn").addEventListener("click", function(){
|
||||
fetch("/api/channels/backup/", {
|
||||
method: "POST",
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(resp.ok) Swal.fire("Success", "Backup created.", "success");
|
||||
else Swal.fire("Error", "Backup failed.", "error");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("restoreBackupBtn").addEventListener("click", function(){
|
||||
fetch("/api/channels/restore/", {
|
||||
method: "POST",
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(resp.ok) Swal.fire("Success", "Restore complete.", "success");
|
||||
else Swal.fire("Error", "Restore failed.", "error");
|
||||
});
|
||||
});
|
||||
|
||||
/* HDHR Emulation */
|
||||
document.getElementById("hdhrEmulationForm").addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
fetch("/api/core/settings/1/", {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(resp.ok) Swal.fire("Success", "HDHR settings saved.", "success");
|
||||
else Swal.fire("Error", "Failed to save HDHR settings.", "error");
|
||||
});
|
||||
});
|
||||
|
||||
/* USERS TABLE */
|
||||
const usersTable = new DataTable("#usersTable", {
|
||||
ajax: { url: "/api/accounts/users", dataSrc: "" },
|
||||
responsive: true,
|
||||
columns: [
|
||||
{ data: "id" },
|
||||
{ data: "username" },
|
||||
{ data: "email" },
|
||||
{ data: "groups" },
|
||||
{
|
||||
data: null,
|
||||
render: row => `
|
||||
<button class="btn btn-sm btn-primary edit-user" data-id="${row.id}">Edit</button>
|
||||
<button class="btn btn-sm btn-danger delete-user" data-id="${row.id}">Delete</button>
|
||||
`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
/* ADD USER */
|
||||
document.getElementById("addUserBtn").addEventListener("click", () => {
|
||||
document.getElementById("userForm").reset();
|
||||
document.getElementById("userModalLabel").textContent = "Add User";
|
||||
document.getElementById("userId").value = "";
|
||||
new bootstrap.Modal(document.getElementById("userModal")).show();
|
||||
});
|
||||
|
||||
/* SAVE USER */
|
||||
document.getElementById("userForm").addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
const idVal = document.getElementById("userId").value;
|
||||
const url = idVal ? `/api/accounts/users/${idVal}/` : "/api/accounts/users/";
|
||||
const method = idVal ? "PUT" : "POST";
|
||||
|
||||
const formData = new FormData(this);
|
||||
fetch(url, {
|
||||
method,
|
||||
body: formData,
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(!resp.ok) throw new Error("Failed to save user");
|
||||
return resp.json();
|
||||
})
|
||||
.then(() => {
|
||||
Swal.fire("Success", "User saved.", "success");
|
||||
usersTable.ajax.reload();
|
||||
bootstrap.Modal.getInstance(document.getElementById("userModal")).hide();
|
||||
})
|
||||
.catch(err => Swal.fire("Error", err.message, "error"));
|
||||
});
|
||||
|
||||
/* EDIT / DELETE USER */
|
||||
$("#usersTable").on("click", ".edit-user", function(){
|
||||
const rowData = usersTable.row($(this).closest("tr")).data();
|
||||
document.getElementById("userModalLabel").textContent = "Edit User";
|
||||
document.getElementById("userId").value = rowData.id;
|
||||
document.getElementById("usernameField").value = rowData.username || "";
|
||||
document.getElementById("emailField").value = rowData.email || "";
|
||||
new bootstrap.Modal(document.getElementById("userModal")).show();
|
||||
});
|
||||
$("#usersTable").on("click", ".delete-user", function(){
|
||||
const rowData = usersTable.row($(this).closest("tr")).data();
|
||||
Swal.fire({
|
||||
title: "Delete this user?",
|
||||
text: `Are you sure you want to delete "${rowData.username}"?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, delete!"
|
||||
}).then(res => {
|
||||
if(res.isConfirmed){
|
||||
fetch(`/api/accounts/users/${rowData.id}/`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(!resp.ok) throw new Error("Failed to delete user");
|
||||
Swal.fire("Deleted!", "User was removed.", "success");
|
||||
usersTable.ajax.reload();
|
||||
})
|
||||
.catch(err => Swal.fire("Error", err.message, "error"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* GROUPS TABLE */
|
||||
const groupsTable = new DataTable("#groupsTable", {
|
||||
ajax: { url: "/api/accounts/groups/", dataSrc: "" },
|
||||
responsive: true,
|
||||
columns: [
|
||||
{ data: "id" },
|
||||
{ data: "name" },
|
||||
{
|
||||
data: "permissions",
|
||||
render: perms => {
|
||||
if(!perms || !perms.length) return "";
|
||||
return perms.join(", ");
|
||||
}
|
||||
},
|
||||
{
|
||||
data: null,
|
||||
render: row => `
|
||||
<button class="btn btn-sm btn-primary edit-group" data-id="${row.id}">Edit</button>
|
||||
<button class="btn btn-sm btn-danger delete-group" data-id="${row.id}">Delete</button>
|
||||
`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
/* ADD GROUP */
|
||||
document.getElementById("addGroupBtn").addEventListener("click", () => {
|
||||
document.getElementById("groupForm").reset();
|
||||
document.getElementById("groupModalLabel").textContent = "Add Group";
|
||||
document.getElementById("groupId").value = "";
|
||||
new bootstrap.Modal(document.getElementById("groupModal")).show();
|
||||
});
|
||||
|
||||
/* SAVE GROUP */
|
||||
document.getElementById("groupForm").addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
const idVal = document.getElementById("groupId").value;
|
||||
const url = idVal ? `/api/accounts/groups/${idVal}/` : "/api/accounts/groups/";
|
||||
const method = idVal ? "PUT" : "POST";
|
||||
|
||||
const formData = new FormData(this);
|
||||
fetch(url, {
|
||||
method,
|
||||
body: formData,
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(!resp.ok) throw new Error("Failed to save group");
|
||||
return resp.json();
|
||||
})
|
||||
.then(() => {
|
||||
Swal.fire("Success", "Group saved.", "success");
|
||||
groupsTable.ajax.reload();
|
||||
bootstrap.Modal.getInstance(document.getElementById("groupModal")).hide();
|
||||
})
|
||||
.catch(err => Swal.fire("Error", err.message, "error"));
|
||||
});
|
||||
|
||||
/* EDIT / DELETE GROUP */
|
||||
$("#groupsTable").on("click", ".edit-group", function(){
|
||||
const rowData = groupsTable.row($(this).closest("tr")).data();
|
||||
document.getElementById("groupModalLabel").textContent = "Edit Group";
|
||||
document.getElementById("groupId").value = rowData.id;
|
||||
document.getElementById("groupNameField").value = rowData.name || "";
|
||||
new bootstrap.Modal(document.getElementById("groupModal")).show();
|
||||
});
|
||||
$("#groupsTable").on("click", ".delete-group", function(){
|
||||
const rowData = groupsTable.row($(this).closest("tr")).data();
|
||||
Swal.fire({
|
||||
title: "Delete this group?",
|
||||
text: `Are you sure you want to delete "${rowData.name}"?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, delete!"
|
||||
}).then(res => {
|
||||
if(res.isConfirmed){
|
||||
fetch(`/api/accounts/groups/${rowData.id}/`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
})
|
||||
.then(resp => {
|
||||
if(!resp.ok) throw new Error("Failed to delete group");
|
||||
Swal.fire("Deleted!", "Group was removed.", "success");
|
||||
groupsTable.ajax.reload();
|
||||
})
|
||||
.catch(err => Swal.fire("Error", err.message, "error"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue