implement pulls
This commit is contained in:
parent
813780a18b
commit
d2f980c318
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"venvPath": "source",
|
||||||
|
"venv": ".venv"
|
||||||
|
}
|
|
@ -1,11 +1,12 @@
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from .models import Task
|
from .models import Task
|
||||||
from config.signals import stream_inactive
|
from config.signals_shared import stream_inactive
|
||||||
from config.models import Stream
|
from config.models import Stream
|
||||||
|
|
||||||
|
|
||||||
@receiver(stream_inactive)
|
@receiver(stream_inactive)
|
||||||
def delete_tasks(sender, **kwargs):
|
def delete_tasks_when_stream_inactive(sender, **kwargs):
|
||||||
# when a stream was unpublished, all related tasks need to be deleted.
|
# when a stream has become inactive, all related tasks which are created for the stream need to be deleted.
|
||||||
|
# we need to exclude the pull tasks, as they are not created for the stream.
|
||||||
stream = Stream.objects.get(stream=kwargs['stream'])
|
stream = Stream.objects.get(stream=kwargs['stream'])
|
||||||
Task.objects.filter(stream=stream).delete()
|
Task.objects.filter(stream=stream).exclude(type="pull").delete()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from guardian.admin import GuardedModelAdmin
|
from guardian.admin import GuardedModelAdmin
|
||||||
from config.models import Stream, Restream, SRSNode, SRSStreamInstance
|
from config.models import Stream, Restream, Pull, SRSNode, SRSStreamInstance
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Stream)
|
@admin.register(Stream)
|
||||||
|
@ -11,6 +11,10 @@ class StreamAdmin(GuardedModelAdmin):
|
||||||
class RestreamAdmin(GuardedModelAdmin):
|
class RestreamAdmin(GuardedModelAdmin):
|
||||||
fields = ['name', 'active', 'stream', 'format', 'target']
|
fields = ['name', 'active', 'stream', 'format', 'target']
|
||||||
|
|
||||||
|
@admin.register(Pull)
|
||||||
|
class PullAdmin(GuardedModelAdmin):
|
||||||
|
fields = ['name', 'active', 'stream', 'source']
|
||||||
|
|
||||||
@admin.register(SRSNode)
|
@admin.register(SRSNode)
|
||||||
class SRSNodeAdmin(GuardedModelAdmin):
|
class SRSNodeAdmin(GuardedModelAdmin):
|
||||||
fields = ['name', 'api_base', 'rtmp_base', 'active']
|
fields = ['name', 'api_base', 'rtmp_base', 'active']
|
||||||
|
|
|
@ -22,6 +22,20 @@ class RestreamPatch(ModelSchema):
|
||||||
extra = "forbid"
|
extra = "forbid"
|
||||||
|
|
||||||
|
|
||||||
|
class Pull(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = models.Pull
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class PullPatch(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = models.Pull
|
||||||
|
exclude = ["id"]
|
||||||
|
fields_optional = "__all__"
|
||||||
|
extra = "forbid"
|
||||||
|
|
||||||
|
|
||||||
class Stream(ModelSchema):
|
class Stream(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Stream
|
model = models.Stream
|
||||||
|
@ -87,6 +101,7 @@ def delete_stream(request, id: int):
|
||||||
|
|
||||||
stream.delete()
|
stream.delete()
|
||||||
|
|
||||||
|
####################################################################################################
|
||||||
|
|
||||||
@router.get('/restreams', response=List[Restream])
|
@router.get('/restreams', response=List[Restream])
|
||||||
def list_restreams(request):
|
def list_restreams(request):
|
||||||
|
@ -142,3 +157,60 @@ def delete_restream(request, id: int):
|
||||||
raise HttpError(401, "unauthorized")
|
raise HttpError(401, "unauthorized")
|
||||||
|
|
||||||
restream.delete()
|
restream.delete()
|
||||||
|
|
||||||
|
####################################################################################################
|
||||||
|
|
||||||
|
@router.get('/pulls', response=List[Pull])
|
||||||
|
def list_pulls(request):
|
||||||
|
return get_objects_for_user(request.user, 'view_pull', models.Pull.objects.all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/pulls', response=Pull)
|
||||||
|
def create_pull(request, payload: Pull):
|
||||||
|
if not request.user.has_perm('view_stream', payload.stream):
|
||||||
|
raise HttpError(401, "unauthorized")
|
||||||
|
|
||||||
|
pull = models.Pull.objects.create(**payload.dict())
|
||||||
|
assign_perm( 'view_pull', request.user, Pull)
|
||||||
|
assign_perm('change_pull', request.user, Pull)
|
||||||
|
assign_perm('delete_pull', request.user, Pull)
|
||||||
|
return pull
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/pulls/{id}', response=Pull)
|
||||||
|
def get_pull(request, id: int):
|
||||||
|
pull = get_object_or_404(models.Pull, id=id)
|
||||||
|
|
||||||
|
if not request.user.has_perm('view_pull', pull):
|
||||||
|
raise HttpError(401, "unauthorized")
|
||||||
|
|
||||||
|
return pull
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch('/pulls/{id}', response=Pull)
|
||||||
|
def patch_pull(request, id: int, payload: PullPatch):
|
||||||
|
pull = get_object_or_404(models.Pull, id=id)
|
||||||
|
|
||||||
|
if not request.user.has_perm('change_pull', pull):
|
||||||
|
raise HttpError(401, "unauthorized")
|
||||||
|
|
||||||
|
if payload.stream:
|
||||||
|
payload.stream = get_object_or_404(models.Stream, id=payload.stream)
|
||||||
|
|
||||||
|
if not request.user.has_perm('view_stream', payload.stream):
|
||||||
|
raise HttpError(401, "unauthorized")
|
||||||
|
|
||||||
|
for key, value in payload.dict(exclude_unset=True).items():
|
||||||
|
setattr(pull, key, value)
|
||||||
|
pull.save()
|
||||||
|
return pull
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/pulls/{id}', response=None)
|
||||||
|
def delete_pull(request, id: int):
|
||||||
|
pull = get_object_or_404(models.Pull, id=id)
|
||||||
|
|
||||||
|
if not request.user.has_perm('delete_pull', pull):
|
||||||
|
raise HttpError(401, "unauthorized")
|
||||||
|
|
||||||
|
pull.delete()
|
|
@ -6,4 +6,4 @@ class ConfigConfig(AppConfig):
|
||||||
name = 'config'
|
name = 'config'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import config.signals, config.signals_shared # noqa
|
import config.signals.pull, config.signals.restream, config.signals_shared # noqa
|
||||||
|
|
|
@ -14,3 +14,15 @@ class RestreamFilteredStreamForm(ModelForm):
|
||||||
|
|
||||||
# limit the stream selection to user-accessible streams
|
# limit the stream selection to user-accessible streams
|
||||||
self.fields['stream'].queryset = get_objects_for_user(user, 'config.view_stream')
|
self.fields['stream'].queryset = get_objects_for_user(user, 'config.view_stream')
|
||||||
|
|
||||||
|
class PullFilteredStreamForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Pull
|
||||||
|
fields = ['name', 'stream', 'source', 'active']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop('user', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# limit the stream selection to user-accessible streams
|
||||||
|
self.fields['stream'].queryset = get_objects_for_user(user, 'config.view_stream')
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 5.0.2 on 2024-04-01 08:08
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('config', '0002_srsnode_srsstreaminstance'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Pull',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('source', models.CharField(help_text='pull_source_help', max_length=500)),
|
||||||
|
('active', models.BooleanField(help_text='pull_activate_help')),
|
||||||
|
('name', models.CharField(help_text='pull_name_help', max_length=100)),
|
||||||
|
('stream', models.ForeignKey(help_text='pull_stream_help', on_delete=django.db.models.deletion.CASCADE, to='config.stream')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'pull_verbose_name',
|
||||||
|
'verbose_name_plural': 'pull_verbose_name_plural',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 5.0.2 on 2024-04-01 10:42
|
||||||
|
|
||||||
|
import config.util
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('config', '0003_pull'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='pull',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(help_text='pull_source_help', max_length=500, validators=[config.util.validate_stream_url]),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='restream',
|
||||||
|
name='target',
|
||||||
|
field=models.CharField(help_text='restream_target_help', max_length=500, validators=[config.util.validate_stream_url]),
|
||||||
|
),
|
||||||
|
]
|
|
@ -8,6 +8,7 @@ from django.utils.translation import gettext as _
|
||||||
from django.db.models.signals import pre_delete
|
from django.db.models.signals import pre_delete
|
||||||
from portier.common import handlers
|
from portier.common import handlers
|
||||||
from config import signals_shared
|
from config import signals_shared
|
||||||
|
from config.util import validate_stream_url
|
||||||
|
|
||||||
|
|
||||||
class Stream(models.Model):
|
class Stream(models.Model):
|
||||||
|
@ -41,7 +42,7 @@ class Stream(models.Model):
|
||||||
return _('stream_class_name')
|
return _('stream_class_name')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return str(self.name)
|
||||||
|
|
||||||
|
|
||||||
pre_delete.connect(handlers.remove_obj_perms_connected_with_user, sender=Stream)
|
pre_delete.connect(handlers.remove_obj_perms_connected_with_user, sender=Stream)
|
||||||
|
@ -75,13 +76,64 @@ class SRSStreamInstance(models.Model):
|
||||||
return f"{self.stream} on {self.node}"
|
return f"{self.stream} on {self.node}"
|
||||||
|
|
||||||
|
|
||||||
|
class Pull(models.Model):
|
||||||
|
stream = models.ForeignKey(Stream, on_delete=models.CASCADE, help_text=_('pull_stream_help'))
|
||||||
|
source = models.CharField(max_length=500, validators=[validate_stream_url], help_text=_('pull_source_help'))
|
||||||
|
active = models.BooleanField(help_text=_('pull_activate_help'))
|
||||||
|
name = models.CharField(max_length=100, help_text=_('pull_name_help'))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('pull_verbose_name')
|
||||||
|
verbose_name_plural = _('pull_verbose_name_plural')
|
||||||
|
|
||||||
|
def class_name(self):
|
||||||
|
return _('pull_class_name')
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('config:pull_detail', kwargs={'pk': self.pk})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.name)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
if self.active:
|
||||||
|
signals_shared.pull_active.send(
|
||||||
|
sender=self.__class__,
|
||||||
|
pull_id=self.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
signals_shared.pull_inactive.send(
|
||||||
|
sender=self.__class__,
|
||||||
|
pull_id=self.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_concierge_configuration(self):
|
||||||
|
if not self.active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Select a random node that is active to ingest the pulled stream.
|
||||||
|
node = SRSNode.objects.filter(active=True).order_by('?').first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "pull",
|
||||||
|
"active": self.active,
|
||||||
|
"name": self.name,
|
||||||
|
"source": self.source,
|
||||||
|
"target": f"{node.rtmp_base}/{settings.GLOBAL_STREAM_NAMESPACE}/{self.stream.stream}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pre_delete.connect(handlers.remove_obj_perms_connected_with_user, sender=Pull)
|
||||||
|
|
||||||
|
|
||||||
class Restream(models.Model):
|
class Restream(models.Model):
|
||||||
FORMATS = (
|
FORMATS = (
|
||||||
('flv', 'flv (RTMP)'),
|
('flv', 'flv (RTMP)'),
|
||||||
('mpegts', 'mpegts (SRT)'),
|
('mpegts', 'mpegts (SRT)'),
|
||||||
)
|
)
|
||||||
stream = models.ForeignKey(Stream, on_delete=models.CASCADE, help_text=_('restream_stream_help'))
|
stream = models.ForeignKey(Stream, on_delete=models.CASCADE, help_text=_('restream_stream_help'))
|
||||||
target = models.CharField(max_length=500, help_text=_('restream_target_help'))
|
target = models.CharField(max_length=500, validators=[validate_stream_url], help_text=_('restream_target_help'))
|
||||||
name = models.CharField(max_length=100, help_text=_('restream_name_help'))
|
name = models.CharField(max_length=100, help_text=_('restream_name_help'))
|
||||||
active = models.BooleanField(help_text=_('restream_activate_help'))
|
active = models.BooleanField(help_text=_('restream_activate_help'))
|
||||||
format = models.CharField(max_length=6, choices=FORMATS, default='flv', help_text=_('restream_format_help'))
|
format = models.CharField(max_length=6, choices=FORMATS, default='flv', help_text=_('restream_format_help'))
|
||||||
|
@ -128,4 +180,3 @@ class Restream(models.Model):
|
||||||
|
|
||||||
|
|
||||||
pre_delete.connect(handlers.remove_obj_perms_connected_with_user, sender=Restream)
|
pre_delete.connect(handlers.remove_obj_perms_connected_with_user, sender=Restream)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.db.models.signals import post_save, post_delete
|
||||||
|
|
||||||
|
from config.models import Stream, Pull
|
||||||
|
from config.signals_shared import stream_active, stream_inactive, pull_active, pull_inactive
|
||||||
|
from concierge.models import Task
|
||||||
|
|
||||||
|
@receiver(pull_active)
|
||||||
|
def create_pull_tasks_on_activate(sender, **kwargs):
|
||||||
|
pull = Pull.objects.get(id=kwargs['pull_id'])
|
||||||
|
Task.objects.get_or_create(stream=pull.stream, type='pull', config_id=pull.id,
|
||||||
|
configuration=json.dumps(pull.get_concierge_configuration()))
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pull_inactive)
|
||||||
|
def delete_pull_tasks_on_inactivate(sender, **kwargs):
|
||||||
|
pull = Pull.objects.get(id=kwargs['pull_id'])
|
||||||
|
Task.objects.filter(type='pull', config_id=pull.id).delete()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Pull)
|
||||||
|
def update_pull_tasks(sender, **kwargs):
|
||||||
|
instance = kwargs['instance']
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = Task.objects.filter(type='pull', config_id=instance.id).get()
|
||||||
|
task.delete()
|
||||||
|
except Task.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if instance.active:
|
||||||
|
task = Task(stream=instance.stream, type='pull', config_id=instance.id,
|
||||||
|
configuration=json.dumps(instance.get_concierge_configuration()))
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=Pull)
|
||||||
|
def delete_pull_tasks(sender, **kwargs):
|
||||||
|
instance = kwargs['instance']
|
||||||
|
# Get the current task instance if it exists, and remove it
|
||||||
|
try:
|
||||||
|
task = Task.objects.filter(type='pull', config_id=instance.id).get()
|
||||||
|
task.delete()
|
||||||
|
except Task.DoesNotExist:
|
||||||
|
pass
|
|
@ -0,0 +1,46 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.db.models.signals import post_save, post_delete
|
||||||
|
|
||||||
|
from config.models import Restream, Stream
|
||||||
|
from config.signals_shared import stream_active, stream_inactive
|
||||||
|
from concierge.models import Task
|
||||||
|
|
||||||
|
@receiver(stream_active)
|
||||||
|
def create_restream_tasks(sender, **kwargs):
|
||||||
|
stream = Stream.objects.get(stream=kwargs['stream'])
|
||||||
|
restreams = Restream.objects.filter(active=True, stream=stream)
|
||||||
|
for restream in restreams:
|
||||||
|
Task.objects.get_or_create(stream=restream.stream, type='restream', config_id=restream.id,
|
||||||
|
configuration=json.dumps(restream.get_concierge_configuration()))
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Restream)
|
||||||
|
def update_restream_tasks(sender, **kwargs):
|
||||||
|
instance = kwargs['instance']
|
||||||
|
# TODO: check for breaking changes using update_fields. This needs custom save_model functions though.
|
||||||
|
|
||||||
|
# Get the current task instance if it exists, and remove it
|
||||||
|
try:
|
||||||
|
task = Task.objects.filter(type='restream', config_id=instance.id).get()
|
||||||
|
task.delete()
|
||||||
|
except Task.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If the configuration is set to be active, and the stream is published, (re)create new task
|
||||||
|
if instance.active and instance.stream.publish_counter > 0:
|
||||||
|
task = Task(stream=instance.stream, type='restream', config_id=instance.id,
|
||||||
|
configuration=json.dumps(instance.get_concierge_configuration()))
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=Restream)
|
||||||
|
def delete_restream_tasks(sender, **kwargs):
|
||||||
|
instance = kwargs['instance']
|
||||||
|
# Get the current task instance if it exists, and remove it
|
||||||
|
try:
|
||||||
|
task = Task.objects.filter(type='restream', config_id=instance.id).get()
|
||||||
|
task.delete()
|
||||||
|
except Task.DoesNotExist:
|
||||||
|
pass
|
|
@ -2,3 +2,5 @@ from django.dispatch import Signal
|
||||||
|
|
||||||
stream_active = Signal()
|
stream_active = Signal()
|
||||||
stream_inactive = Signal()
|
stream_inactive = Signal()
|
||||||
|
pull_active = Signal()
|
||||||
|
pull_inactive = Signal()
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
{% load fontawesome_5 %}
|
||||||
|
|
||||||
|
{% block 'sidenav' %}
|
||||||
|
{% with 'pull' as section %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block 'content' %}
|
||||||
|
<h6>{% trans "confirm_delete_header" %}</h6>
|
||||||
|
<hr class="my-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm border-right">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>{% blocktrans with restream_config_name=object.name %}are_you_sure_you_want_to_delete_"{{ pull_config_name }}"?{% endblocktrans %}</p>
|
||||||
|
{% buttons %}
|
||||||
|
<button type="submit" class="btn btn-danger" value="login">
|
||||||
|
{% fa5_icon 'trash' %} {% trans "delete" %}
|
||||||
|
</button>
|
||||||
|
{% endbuttons %}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,78 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
{% load fontawesome_5 %}
|
||||||
|
{% load guardian_tags %}
|
||||||
|
|
||||||
|
{% get_obj_perms user for object as "obj_perms" %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block 'sidenav' %}
|
||||||
|
{% with 'pull' as section %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block 'content' %}
|
||||||
|
<div class="row justify-content-between">
|
||||||
|
<div class="col">
|
||||||
|
<h6>{% trans "pull_configuration_details_header" %}</h6>
|
||||||
|
</div>
|
||||||
|
{% get_obj_perms user for object as "obj_perms" %}
|
||||||
|
<div class="col-auto">
|
||||||
|
{% if "change_pull" in obj_perms %}
|
||||||
|
<a href="{% url 'config:pull_change' pk=object.pk %}" type="button" class="btn btn-sm btn-outline-primary">{% fa5_icon 'edit' %} {% trans 'change' %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if "delete_pull" in obj_perms %}
|
||||||
|
<a href="{% url 'config:pull_delete' pk=object.pk %}" type="button" class="btn btn-sm btn-outline-danger">{% fa5_icon 'trash' %} {% trans 'delete' %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm border-right">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-3">{% trans "name" %}</dt>
|
||||||
|
<dd class="col-sm-9">{{ object.name }}</dd>
|
||||||
|
<dt class="col-sm-3">{% trans "stream" %}</dt>
|
||||||
|
<dd class="col-sm-9"><a href="{% url 'config:stream_detail' pk=object.stream.pk %}">{{ object.stream.name }}</a></dd>
|
||||||
|
<dt class="col-sm-3">{% trans "active" %}</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
{% if object.active %}
|
||||||
|
<span class="text-success">{% fa5_icon 'check-circle' %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-danger">{% fa5_icon 'times-circle' %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<p>{% trans "configured_source_url" %}</p>
|
||||||
|
<div class="input-group mb-4" id="show_hide_source_url">
|
||||||
|
<input readonly class="form-control" type="password" value="{{ object.source }}">
|
||||||
|
<div class="input-group-append">
|
||||||
|
<a href="" class="btn btn-outline-secondary"><i class="fa fa-eye-slash" aria-hidden="true"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
$(function () {
|
||||||
|
$('[data-toggle="popover"]').popover()
|
||||||
|
})
|
||||||
|
$(function() {
|
||||||
|
$("#show_hide_source_url a").on('click', function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if ($('#show_hide_source_url input').attr('type') == 'text') {
|
||||||
|
$('#show_hide_source_url input').attr('type', 'password');
|
||||||
|
$('#show_hide_source_url i').addClass('fa-eye-slash');
|
||||||
|
$('#show_hide_source_url i').removeClass('fa-eye');
|
||||||
|
} else if ($('#show_hide_source_url input').attr('type') == 'password') {
|
||||||
|
$('#show_hide_source_url input').attr('type', 'text');
|
||||||
|
$('#show_hide_source_url i').removeClass('fa-eye-slash');
|
||||||
|
$('#show_hide_source_url i').addClass('fa-eye');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,31 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block 'sidenav' %}
|
||||||
|
{% with 'pull' as section %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block 'content' %}
|
||||||
|
<h6>{% trans "create_new_pull_configuration_header" %}</h6>
|
||||||
|
<hr class="my-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm border-right">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% buttons %}
|
||||||
|
<button type="submit" class="btn btn-primary" value="login">
|
||||||
|
{% trans "submit" %}
|
||||||
|
</button>
|
||||||
|
{% endbuttons %}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
{% trans "pull_configuration_text_html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,70 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
{% load fontawesome_5 %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block 'sidenav' %}
|
||||||
|
{% with 'pull' as section %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block 'content' %}
|
||||||
|
|
||||||
|
<div class="row justify-content-between">
|
||||||
|
<div class="col">
|
||||||
|
<h6>{% trans "pull_configuration_header" %}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="btn-toolbar" role="toolbar">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="{% url 'config:pull_create' %}" type="button" class="btn btn-sm btn-outline-primary">{% fa5_icon 'plus' %} {% trans "create" %}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-4">
|
||||||
|
<div id="app">
|
||||||
|
<div v-if="isLoading">
|
||||||
|
<div class="my-4 text-center">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="sr-only">{% trans "loading..." %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table v-else class="table">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{% trans "name" %}</th>
|
||||||
|
<th scope="col">{% trans "active" %}</th>
|
||||||
|
<th scope="col" class="text-right">{% trans "actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="cfg in cfgs">
|
||||||
|
<th scope="row">{% verbatim %}{{cfg.name}}{% endverbatim %}</th>
|
||||||
|
<td v-if="cfg.active">
|
||||||
|
<button v-on:click="toggleActive(cfg)" type="button" class="btn btn-outline-success">
|
||||||
|
{% fa5_icon 'check-circle' %}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td v-else>
|
||||||
|
<button v-on:click="toggleActive(cfg)" type="button" class="btn btn-outline-danger">
|
||||||
|
{% fa5_icon 'times-circle' %}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td align="right">
|
||||||
|
<a v-bind:href="detailLink(cfg.id)" type="button" class="btn btn-sm btn-outline-primary">{% trans "details" %}</a>
|
||||||
|
<a v-bind:href="deleteLink(cfg.id)" type="button" class="btn btn-sm btn-outline-danger">{% fa5_icon 'trash' %}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'js/vue.min.js' %}"></script>
|
||||||
|
<script src="{% static 'js/axios.min.js' %}"></script>
|
||||||
|
<script src="{% static 'js/pull-list.js' %}"></script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,31 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block 'sidenav' %}
|
||||||
|
{% with 'pull' as section %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block 'content' %}
|
||||||
|
<h6>{% trans "update_pull_configuration_header" %}</h6>
|
||||||
|
<hr class="my-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm border-right">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% buttons %}
|
||||||
|
<button type="submit" class="btn btn-primary" value="login">
|
||||||
|
{% trans "submit" %}
|
||||||
|
</button>
|
||||||
|
{% endbuttons %}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
{% trans "pull_configuration_text_html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -1,5 +1,5 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from config.views import restream, stream
|
from config.views import restream, stream, pull
|
||||||
from config.views.srs import callback_srs
|
from config.views.srs import callback_srs
|
||||||
|
|
||||||
app_name = 'config'
|
app_name = 'config'
|
||||||
|
@ -16,4 +16,9 @@ urlpatterns = [
|
||||||
path('restream/<int:pk>/change', restream.RestreamUpdate.as_view(), name='restream_change'),
|
path('restream/<int:pk>/change', restream.RestreamUpdate.as_view(), name='restream_change'),
|
||||||
path('restream/<int:pk>/delete', restream.RestreamDelete.as_view(), name='restream_delete'),
|
path('restream/<int:pk>/delete', restream.RestreamDelete.as_view(), name='restream_delete'),
|
||||||
path('restream/create', restream.RestreamCreate.as_view(), name='restream_create'),
|
path('restream/create', restream.RestreamCreate.as_view(), name='restream_create'),
|
||||||
|
path('pull/', pull.PullList.as_view(), name='pull_list'),
|
||||||
|
path('pull/<int:pk>/', pull.PullDetail.as_view(), name='pull_detail'),
|
||||||
|
path('pull/<int:pk>/change', pull.PullUpdate.as_view(), name='pull_change'),
|
||||||
|
path('pull/<int:pk>/delete', pull.PullDelete.as_view(), name='pull_delete'),
|
||||||
|
path('pull/create', pull.PullCreate.as_view(), name='pull_create'),
|
||||||
]
|
]
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
def validate_stream_url(value, protocols=['rtmp://', 'srt://']):
|
||||||
|
# Make sure that the URL uses one of the allowed protocols
|
||||||
|
if not any([value.startswith(protocol) for protocol in protocols]):
|
||||||
|
raise ValidationError(
|
||||||
|
_('Invalid URL: %(value)s. Must start with one of the following: %(protocols)s'),
|
||||||
|
params={'value': value, 'protocols': ', '.join(protocols)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: Add more validation here
|
||||||
|
|
||||||
|
return value
|
|
@ -0,0 +1,76 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
from django.views.generic import ListView, DetailView, CreateView, DeleteView, UpdateView
|
||||||
|
from guardian.decorators import permission_required_or_403
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
|
||||||
|
from config import models, forms
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name='dispatch')
|
||||||
|
@method_decorator(permission_required_or_403('config.add_pull'),
|
||||||
|
name='dispatch')
|
||||||
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
|
class PullList(ListView):
|
||||||
|
model = models.Pull
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name='dispatch')
|
||||||
|
@method_decorator(permission_required_or_403('config.view_pull',
|
||||||
|
(models.Pull, 'pk', 'pk')),
|
||||||
|
name='dispatch')
|
||||||
|
class PullDetail(DetailView):
|
||||||
|
model = models.Pull
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name='dispatch')
|
||||||
|
@method_decorator(permission_required_or_403('config.change_pull',
|
||||||
|
(models.Pull, 'pk', 'pk')),
|
||||||
|
name='dispatch')
|
||||||
|
class PullUpdate(UpdateView):
|
||||||
|
model = models.Pull
|
||||||
|
form_class = forms.PullFilteredStreamForm
|
||||||
|
template_name_suffix = '_update_form'
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['user'] = self.request.user
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name='dispatch')
|
||||||
|
@method_decorator(permission_required_or_403('config.add_pull'),
|
||||||
|
name='dispatch')
|
||||||
|
class PullCreate(CreateView):
|
||||||
|
model = models.Pull
|
||||||
|
form_class = forms.PullFilteredStreamForm
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['user'] = self.request.user
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
valid = super().form_valid(form)
|
||||||
|
if valid:
|
||||||
|
user = self.request.user
|
||||||
|
assign_perm('view_pull', user, self.object)
|
||||||
|
assign_perm('change_pull', user, self.object)
|
||||||
|
assign_perm('delete_pull', user, self.object)
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name='dispatch')
|
||||||
|
@method_decorator(permission_required_or_403('config.delete_pull',
|
||||||
|
(models.Pull, 'pk', 'pk')),
|
||||||
|
name='dispatch')
|
||||||
|
class PullDelete(DeleteView):
|
||||||
|
model = models.Pull
|
||||||
|
success_url = reverse_lazy('config:pull_list')
|
|
@ -10,7 +10,7 @@ core_router = Router()
|
||||||
class UserSchema(ModelSchema):
|
class UserSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ["id", "username", "email"]
|
fields = ["id", "username", "email", "is_staff", "is_superuser", "first_name", "last_name"]
|
||||||
|
|
||||||
@core_router.get("/me", response=UserSchema)
|
@core_router.get("/me", response=UserSchema)
|
||||||
def me(request):
|
def me(request):
|
||||||
|
@ -22,7 +22,7 @@ api = NinjaAPI(
|
||||||
title="Portier API",
|
title="Portier API",
|
||||||
version="2.0.0",
|
version="2.0.0",
|
||||||
description="HTTP API for Portier. Use this to interact with the Portier backend.",
|
description="HTTP API for Portier. Use this to interact with the Portier backend.",
|
||||||
csrf=True,
|
csrf=False, # Disable CSRF for now
|
||||||
)
|
)
|
||||||
|
|
||||||
api.add_router("/", core_router, auth=django_auth, tags=["Core"])
|
api.add_router("/", core_router, auth=django_auth, tags=["Core"])
|
||||||
|
|
|
@ -27,7 +27,8 @@ SECRET_KEY = os.environ.get("SECRET_KEY", default="CHANGE_ME!")
|
||||||
DEBUG = int(os.environ.get("DEBUG", default=0))
|
DEBUG = int(os.environ.get("DEBUG", default=0))
|
||||||
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", default="*").split(" ")
|
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", default="*").split(" ")
|
||||||
CSRF_TRUSTED_ORIGINS = os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", default="http://localhost").split(" ")
|
CSRF_TRUSTED_ORIGINS = os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", default="http://localhost").split(" ")
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = bool(os.environ.get("DEBUG", default=False))
|
||||||
|
SESSION_COOKIE_SAMESITE = 'None'
|
||||||
|
|
||||||
DEFAULT_GROUP = 'default'
|
DEFAULT_GROUP = 'default'
|
||||||
GLOBAL_STREAM_NAMESPACE = 'live'
|
GLOBAL_STREAM_NAMESPACE = 'live'
|
||||||
|
|
|
@ -6,6 +6,8 @@ django-fontawesome-5
|
||||||
django-celery-beat
|
django-celery-beat
|
||||||
django-filter
|
django-filter
|
||||||
django-ninja
|
django-ninja
|
||||||
|
django-cors-headers
|
||||||
|
django-types
|
||||||
celery>=5.3
|
celery>=5.3
|
||||||
gunicorn>=20
|
gunicorn>=20
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
var app = new Vue({
|
||||||
|
el: '#app',
|
||||||
|
data: {
|
||||||
|
cfgs: [],
|
||||||
|
isLoading: true
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
detailLink(id) {
|
||||||
|
return `${id}/`
|
||||||
|
},
|
||||||
|
deleteLink(id) {
|
||||||
|
return `${id}/delete`
|
||||||
|
},
|
||||||
|
toggleActive(cfg) {
|
||||||
|
axios
|
||||||
|
.patch('/api/v2/config/pulls/' + cfg.id, { active: !cfg.active })
|
||||||
|
.then(response => {
|
||||||
|
i = this.cfgs.findIndex((obj => obj.id == cfg.id))
|
||||||
|
Vue.set(this.cfgs, i, response.data)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
fetchData() {
|
||||||
|
axios
|
||||||
|
.get('/api/v2/config/pulls')
|
||||||
|
.then(response => {
|
||||||
|
this.cfgs = response.data
|
||||||
|
this.isLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||||
|
axios.defaults.xsrfHeaderName = 'X-CSRFTOKEN'
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
})
|
|
@ -84,7 +84,7 @@
|
||||||
<a class="nav-link{% if not perms.config.add_restream %} disabled{% endif %}{% if section == "restream" %} active {% endif %}" href="{% url 'config:restream_list' %}">{% fa5_icon 'expand-arrows-alt' %} {% trans "navbar_configuration_restream" %}</a>
|
<a class="nav-link{% if not perms.config.add_restream %} disabled{% endif %}{% if section == "restream" %} active {% endif %}" href="{% url 'config:restream_list' %}">{% fa5_icon 'expand-arrows-alt' %} {% trans "navbar_configuration_restream" %}</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link disabled" href="{% url 'config:stream_list' %}">{% fa5_icon 'compress-arrows-alt' %} {% trans "navbar_configuration_pull" %}</a>
|
<a class="nav-link{% if not perms.config.add_pull %} disable{% endif %}{% if section == "pull" %} active {% endif %}" href="{% url 'config:pull_list' %}">{% fa5_icon 'compress-arrows-alt' %} {% trans "navbar_configuration_pull" %}</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link disabled" href="{% url 'config:stream_list' %}">{% fa5_icon 'broadcast-tower' %} {% trans "navbar_configuration_publish" %}</a>
|
<a class="nav-link disabled" href="{% url 'config:stream_list' %}">{% fa5_icon 'broadcast-tower' %} {% trans "navbar_configuration_publish" %}</a>
|
||||||
|
|
Loading…
Reference in New Issue