implement pulls

This commit is contained in:
Jan Koppe 2024-04-01 16:27:31 +02:00
parent 813780a18b
commit d2f980c318
Signed by: thunfisch
GPG Key ID: BE935B0735A2129B
26 changed files with 684 additions and 18 deletions

4
pyrightconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
"venvPath": "source",
"venv": ".venv"
}

View File

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

View File

@ -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']
@ -24,4 +28,4 @@ class SRSStreamInstanceAdmin(GuardedModelAdmin):
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
return False return False
def has_add_permission(self, request): def has_add_permission(self, request):
return False return False

View File

@ -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):
@ -141,4 +156,61 @@ def delete_restream(request, id: int):
if not request.user.has_perm('delete_restream', restream): if not request.user.has_perm('delete_restream', restream):
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

@ -1,4 +1,6 @@
from django.dispatch import Signal from django.dispatch import Signal
stream_active = Signal() stream_active = Signal()
stream_inactive = Signal() stream_inactive = Signal()
pull_active = Signal()
pull_inactive = Signal()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

14
source/config/util.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -6,8 +6,10 @@ 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
requests requests
redis redis

View File

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

View File

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