merged in upstream

This commit is contained in:
kappa118 2025-02-24 16:31:51 -05:00
commit 0ca783719d
16 changed files with 1080 additions and 211 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,6 @@
from django.urls import path
from .views import settings_view
urlpatterns = [
path('', settings_view, name='settings'),
]

View file

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

View file

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

View file

@ -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
View 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 %}

View file

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