diff --git a/.coveragerc b/.coveragerc
index d09282673e..0b95328c72 100755
--- a/.coveragerc
+++ b/.coveragerc
@@ -16,3 +16,4 @@ omit = pod/*settings*.py
pod/*/forms.py
pod/video/bbb.py
scripts/bbb-pod-live/*.*
+ pod/live/pilotingInterface.py
diff --git a/.github/workflows/pod.yml b/.github/workflows/pod.yml
index b7f3698786..9215611da0 100644
--- a/.github/workflows/pod.yml
+++ b/.github/workflows/pod.yml
@@ -45,6 +45,7 @@ jobs:
pip install -r requirements.txt
pip install flake8
pip install coveralls
+ pip install httmock
- name: Flake8 compliance
run: |
diff --git a/README.md b/README.md
index 9ff2843ffc..a43888c5c1 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://github.com/EsupPortail/Esup-Pod/blob/master/LICENSE) [](https://github.com/EsupPortail/Esup-Pod/actions) [](https://coveralls.io/github/EsupPortail/Esup-Pod?branch=master)
-
+[FR]
## Plateforme de gestion de fichier vidéo
Créé en 2014, le projet Pod a connu de nombreux changement ces dernières années. Initié à l’[Université de Lille](https://www.univ-lille.fr/), il est depuis septembre 2015 piloté par le consortium [Esup Portail](https://www.esup-portail.org/) et soutenu également par le [Ministère de l’Enseignement supérieur, de la Recherche et de l’Innovation](http://www.enseignementsup-recherche.gouv.fr/).
@@ -12,6 +12,14 @@ Le projet et la plateforme qui porte le même nom ont pour but de faciliter la m
### Documentation technique
* Accédez à toute la documentation (installation, paramétrage etc.) [sur notre wiki](https://www.esup-portail.org/wiki/display/ES/esup-pod "Documentation")
+[EN]
+## Platform management of video files
+Created in 2014 at the university of [Lille](https://www.univ-lille.fr/), the POD project has been managed by the [Esup Portail consortium](https://www.esup-portail.org/) and supported by the [Ministry of Higher Education, Research and Innovation](http://www.enseignementsup-recherche.gouv.fr/) since September 2015 .
+
+The project and the platform of the same name are aimed at users of our institutions, by allowing the publication of videos in the fields of research (promotion of platforms, etc.), training (tutorials, distance training, student reports, etc.), institutional life (video of events), offering several days of content.
+
+### Technical documentation
+* The documentation (to install, customize, etc...) is on the [ESUP Wiki](https://www.esup-portail.org/wiki/display/ES/esup-pod "Documentation")
|
|
Ministère de lʼEnseignement supérieur, de la Recherche et de lʼInnovation
:-----:|:-----:|:----:
diff --git a/pod/authentication/admin.py b/pod/authentication/admin.py
index 10b50c9744..8a3ffa6388 100644
--- a/pod/authentication/admin.py
+++ b/pod/authentication/admin.py
@@ -113,6 +113,13 @@ def clickable_email(self, obj):
if USE_ESTABLISHMENT_FIELD:
list_display = list_display + ("owner_establishment",)
+ # readonly_fields=('is_superuser',)
+ def get_readonly_fields(self, request, obj=None):
+ if request.user.is_superuser:
+ return []
+ self.readonly_fields += ("is_superuser",)
+ return self.readonly_fields
+
def owner_hashkey(self, obj):
return "%s" % Owner.objects.get(user=obj).hashkey
diff --git a/pod/authentication/models.py b/pod/authentication/models.py
index 5d297be2a3..29254655cc 100644
--- a/pod/authentication/models.py
+++ b/pod/authentication/models.py
@@ -170,7 +170,7 @@ def create_groupsite_profile(sender, instance, created, **kwargs):
class AccessGroup(models.Model):
display_name = models.CharField(max_length=128, blank=True, default="")
- code_name = models.CharField(max_length=128, unique=True)
+ code_name = models.CharField(max_length=250, unique=True)
sites = models.ManyToManyField(Site)
users = select2_fields.ManyToManyField(
Owner,
diff --git a/pod/authentication/populatedCASbackend.py b/pod/authentication/populatedCASbackend.py
index 9a92aa99fb..8265c5e754 100644
--- a/pod/authentication/populatedCASbackend.py
+++ b/pod/authentication/populatedCASbackend.py
@@ -219,14 +219,16 @@ def populate_user_from_entry(user, owner, entry):
)
else ""
)
- user.last_name = (
- entry[USER_LDAP_MAPPING_ATTRIBUTES["last_name"]].value
- if (
- USER_LDAP_MAPPING_ATTRIBUTES.get("last_name")
- and entry[USER_LDAP_MAPPING_ATTRIBUTES["last_name"]]
+ user.last_name = ""
+ if (
+ USER_LDAP_MAPPING_ATTRIBUTES.get("last_name")
+ and entry[USER_LDAP_MAPPING_ATTRIBUTES["last_name"]]
+ ):
+ user.last_name = (
+ entry[USER_LDAP_MAPPING_ATTRIBUTES["last_name"]].value[0]
+ if (isinstance(entry[USER_LDAP_MAPPING_ATTRIBUTES["last_name"]].value, list))
+ else entry[USER_LDAP_MAPPING_ATTRIBUTES["last_name"]].value
)
- else ""
- )
user.save()
owner.affiliation = (
entry[USER_LDAP_MAPPING_ATTRIBUTES["primaryAffiliation"]].value
diff --git a/pod/authentication/rest_views.py b/pod/authentication/rest_views.py
index c117d2cb3b..356d7523a9 100644
--- a/pod/authentication/rest_views.py
+++ b/pod/authentication/rest_views.py
@@ -56,6 +56,7 @@ class Meta:
"email",
"groups",
)
+ filter_fields = ("id", "username", "email")
class GroupSerializer(serializers.HyperlinkedModelSerializer):
@@ -87,6 +88,7 @@ class OwnerViewSet(viewsets.ModelViewSet):
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by("-date_joined")
serializer_class = UserSerializer
+ filter_fields = ("id", "username", "email")
class GroupViewSet(viewsets.ModelViewSet):
diff --git a/pod/custom/settings_local.py.example b/pod/custom/settings_local.py.example
index b59d992ae6..20e957190b 100644
--- a/pod/custom/settings_local.py.example
+++ b/pod/custom/settings_local.py.example
@@ -996,6 +996,11 @@ CREATE_GROUP_FROM_GROUPS = False
"""
AFFILIATION_STAFF = ('faculty', 'employee', 'staff')
+"""
+# Groupes ou affiliations des personnes autorisées à créer un évènement
+"""
+AFFILIATION_EVENT = ['faculty', 'employee', 'staff']
+
"""
# Valeurs possibles pour l'Affiliation du compte
"""
@@ -1170,11 +1175,6 @@ DS_PARAM = {
}
}
-"""
-# La liste des utilisateurs regardant le direct sera réservée au staff
-"""
-VIEWERS_ONLY_FOR_STAFF = False
-
"""
# Temps (en seconde) entre deux envois d'un signal au serveur,
# pour signaler la présence sur un live
@@ -1361,3 +1361,52 @@ COOKIE_LEARN_MORE = ""
# 'TRACKING_TEMPLATE': 'custom/tracking.html',
"""
USE_VIDEO_EVENT_TRACKING = False
+
+"""
+# Affiche ou non les prochains évènements sur la page d'accueil
+"""
+SHOW_EVENTS_ON_HOMEPAGE = False
+
+"""
+# Chemin racine du répertoire où sont déposés temporairement les
+# enregistrements des évènements éffectués depuis POD pour
+# converstion en ressource vidéo (VOD)
+"""
+DEFAULT_EVENT_PATH = ""
+
+"""
+# Types de logiciel de serveur de streaming utilisés.
+# Actuellement disponible Wowza.
+# Si vous utilisez une autre logiciel, il faut développer une interface dans pod/live/pilotingInterface.py
+"""
+BROADCASTER_PILOTING_SOFTWARE = ['Wowza',]
+
+"""
+# Image par défaut affichée comme poster ou vignette,
+# utilisée pour présenter l'évènement'.
+# Cette image doit se situer dans le répertoire static.
+"""
+DEFAULT_EVENT_THUMBNAIL = "/img/default-event.svg"
+
+"""
+# Type par défaut affecté à un évènement direct
+# (en général, le type ayant pour identifiant '1' est 'Other')
+"""
+DEFAULT_EVENT_TYPE_ID = 1
+
+
+"""
+# Si True, un courriel est envoyé aux managers
+# et à l'auteur (si DEBUG est à False) à la création/modification d'un event
+"""
+EMAIL_ON_EVENT_SCHEDULING = True
+
+"""
+# Si True, un courriel est également envoyé à l'admin (si DEBUG est à False)
+# à la création/modification d'un event
+"""
+EMAIL_ADMIN_ON_EVENT_SCHEDULING = True
+"""
+# Durée (en nombre de jours) sur laquelle on souhaite compter le nombre de vues récentes
+"""
+VIDEO_RECENT_VIEWCOUNT = 180
\ No newline at end of file
diff --git a/pod/custom/tenants/check_obsolete_videos.sh b/pod/custom/tenants/check_obsolete_videos.sh
deleted file mode 100644
index 70cd0d67e7..0000000000
--- a/pod/custom/tenants/check_obsolete_videos.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/bash
-
-# Main
-cd /usr/local/django_projects/podv2 && /home/pod/.virtualenvs/django_pod/bin/python manage.py check_obsolete_videos >> /usr/local/django_projects/podv2/pod/log/cron_obsolete.log 2>&1
\ No newline at end of file
diff --git a/pod/custom/tenants/clearsessions.sh b/pod/custom/tenants/clearsessions.sh
deleted file mode 100644
index 4260a68d73..0000000000
--- a/pod/custom/tenants/clearsessions.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/bash
-# main
-cd /usr/local/django_projects/podv2 && /home/pod/.virtualenvs/django_pod/bin/python manage.py clearsessions &>> /usr/local/django_projects/podv2/pod/log/cron_clearsessions.log 2>&1
\ No newline at end of file
diff --git a/pod/custom/tenants/create_tenant.sh b/pod/custom/tenants/create_tenant.sh
index ed77b241c9..9da2138d83 100644
--- a/pod/custom/tenants/create_tenant.sh
+++ b/pod/custom/tenants/create_tenant.sh
@@ -73,19 +73,23 @@ echo "(django_pod) pod@pod:/usr/local/django_projects/podv2$ python manage.py cr
echo "--"
echo "crontab"
echo "clear session"
-echo "# $ID_SITE $NAME " >> $BASEDIR/clearsessions.sh
+echo "" >> $BASEDIR/sh_tenants/clearsessions.sh
+echo "# $ID_SITE $NAME " >> $BASEDIR/sh_tenants/clearsessions.sh
echo "cd /usr/local/django_projects/podv2 && /home/pod/.virtualenvs/django_pod/bin/python manage.py clearsessions --settings=pod.custom.tenants.$NAME."$NAME"_settings &>> /usr/local/django_projects/podv2/pod/log/cron_clearsessions_$NAME.log 2>&1" >> $BASEDIR/clearsessions.sh
echo "index videos"
-echo "# $ID_SITE $NAME " >> $BASEDIR/index_videos.sh
+echo "" >> $BASEDIR/sh_tenants/index_videos.sh
+echo "# $ID_SITE $NAME " >> $BASEDIR/sh_tenants/index_videos.sh
echo "cd /usr/local/django_projects/podv2 && /home/pod/.virtualenvs/django_pod/bin/python manage.py index_videos --all --settings=pod.custom.tenants.$NAME."$NAME"_settings &>> /usr/local/django_projects/podv2/pod/log/cron_index_$NAME.log 2>&1" >> $BASEDIR/index_videos.sh
echo "check_obsolete_videos"
-echo "# $ID_SITE $NAME " >> $BASEDIR/check_obsolete_videos.sh
+echo "" >> $BASEDIR/sh_tenants/check_obsolete_videos.sh
+echo "# $ID_SITE $NAME " >> $BASEDIR/sh_tenants/check_obsolete_videos.sh
echo "cd /usr/local/django_projects/podv2 && /home/pod/.virtualenvs/django_pod/bin/python manage.py check_obsolete_videos --settings=pod.custom.tenants.$NAME."$NAME"_settings &>> /usr/local/django_projects/podv2/pod/log/cron_obsolete_$NAME.log 2>&1" >> $BASEDIR/check_obsolete_videos.sh
echo "live_viewcounter"
-echo "# $ID_SITE $NAME " >> $BASEDIR/live_viewcounter.sh
+echo "" >> $BASEDIR/sh_tenants/live_viewcounter.sh
+echo "# $ID_SITE $NAME " >> $BASEDIR/sh_tenants/live_viewcounter.sh
echo "cd /usr/local/django_projects/podv2 && /home/pod/.virtualenvs/django_pod/bin/python manage.py live_viewcounter --settings=pod.custom.tenants.$NAME."$NAME"_settings &>> /usr/local/django_projects/podv2/pod/log/cron_viewcounter_$NAME.log 2>&1" >> $BASEDIR/live_viewcounter.sh
echo "FIN"
diff --git a/pod/custom/tenants/index_videos.sh b/pod/custom/tenants/index_videos.sh
deleted file mode 100644
index 066e10e80a..0000000000
--- a/pod/custom/tenants/index_videos.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/bash
-# main
-cd /usr/local/django_projects/podv2 && /home/pod/.virtualenvs/django_pod/bin/python manage.py index_videos --all &>> /usr/local/django_projects/podv2/pod/log/cron_index.log 2>&1
\ No newline at end of file
diff --git a/pod/custom/tenants/live_viewcounter.sh b/pod/custom/tenants/live_viewcounter.sh
deleted file mode 100644
index a3c798c22a..0000000000
--- a/pod/custom/tenants/live_viewcounter.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/bash
-# Main
-cd /usr/local/django_projects/podv2 && /home/pod/.virtualenvs/django_pod/bin/python manage.py live_viewcounter >> /usr/local/django_projects/podv2/pod/log/cron_viewcounter.log 2>&1
diff --git a/pod/custom/tenants/source/tenant_settings.py b/pod/custom/tenants/source/tenant_settings.py
index 509afced03..479eff22c3 100644
--- a/pod/custom/tenants/source/tenant_settings.py
+++ b/pod/custom/tenants/source/tenant_settings.py
@@ -44,3 +44,9 @@
DEFAULT_DC_RIGHTS = "BY-NC-SA"
CELERY_BROKER_URL = CELERY_BROKER_URL + "-__NAME__" # Define a broker
+
+
+########################################################## A LAISSER EN DERNIER !!!!!!!!!!!!!
+the_update_settings = update_settings(locals())
+for variable in the_update_settings:
+ locals()[variable] = the_update_settings[variable]
diff --git a/pod/custom/tenants/source_enc/tenant_enc_settings.py b/pod/custom/tenants/source_enc/tenant_enc_settings.py
index bac9727254..1660130d4b 100644
--- a/pod/custom/tenants/source_enc/tenant_enc_settings.py
+++ b/pod/custom/tenants/source_enc/tenant_enc_settings.py
@@ -21,3 +21,8 @@
CELERY_TO_ENCODE = True # Active encode
CELERY_BROKER_URL = CELERY_BROKER_URL + "-__NAME__" # Define a broker
+
+########################################################## A LAISSER EN DERNIER !!!!!!!!!!!!!
+the_update_settings = update_settings(locals())
+for variable in the_update_settings:
+ locals()[variable] = the_update_settings[variable]
diff --git a/pod/live/admin.py b/pod/live/admin.py
index c7220681d1..a23c13e663 100644
--- a/pod/live/admin.py
+++ b/pod/live/admin.py
@@ -1,16 +1,26 @@
+from django.conf import settings
from django.contrib import admin
-
-from .models import Building
-from .models import Broadcaster
-from .models import HeartBeat
from django.contrib.sites.models import Site
-from pod.live.forms import BuildingAdminForm
-from .forms import BroadcasterAdminForm
from django.contrib.sites.shortcuts import get_current_site
-from pod.video.models import Video
+from django.forms import Textarea
+from django.utils.html import format_html
+from django.utils.translation import ugettext_lazy as _
+from js_asset import static
+from sorl.thumbnail import get_thumbnail
+
+from pod.live.forms import BuildingAdminForm, EventAdminForm, BroadcasterAdminForm
+from pod.live.models import Building, Event, Broadcaster, HeartBeat, Video
+
+DEFAULT_EVENT_THUMBNAIL = getattr(
+ settings, "DEFAULT_EVENT_THUMBNAIL", "img/default-event.svg"
+)
# Register your models here.
+FILEPICKER = False
+if getattr(settings, "USE_PODFILE", False):
+ FILEPICKER = True
+
class HeartBeatAdmin(admin.ModelAdmin):
list_display = ("viewkey", "user", "broadcaster", "last_heartbeat")
@@ -64,10 +74,22 @@ class BroadcasterAdmin(admin.ModelAdmin):
"building",
"url",
"status",
+ "is_recording_admin",
"is_restricted",
+ "piloting_conf",
)
readonly_fields = ["slug"]
+ def get_form(self, request, obj=None, **kwargs):
+ kwargs["widgets"] = {
+ "piloting_conf": Textarea(
+ attrs={
+ "placeholder": "{\n 'server_url':'...',\n 'application':'...',\n 'livestream':'...',\n}"
+ }
+ )
+ }
+ return super().get_form(request, obj, **kwargs)
+
def get_queryset(self, request):
qs = super().get_queryset(request)
if not request.user.is_superuser:
@@ -97,6 +119,142 @@ class Media:
)
+class PasswordFilter(admin.SimpleListFilter):
+ title = _("password")
+ parameter_name = "password"
+
+ def lookups(self, request, model_admin):
+ return (
+ ("Yes", _("Yes")),
+ ("No", _("No")),
+ )
+
+ def queryset(self, request, queryset):
+ value = self.value()
+ if value == "No":
+ queryset = queryset.exclude(
+ pk__in=[event.id for event in queryset if event.password]
+ )
+ elif value == "Yes":
+ queryset = queryset.exclude(
+ pk__in=[event.id for event in queryset if not event.password]
+ )
+ return queryset
+
+
+class EventAdmin(admin.ModelAdmin):
+ def get_form(self, request, obj=None, **kwargs):
+ ModelForm = super(EventAdmin, self).get_form(request, obj, **kwargs)
+
+ class ModelFormMetaClass(ModelForm):
+ def __new__(cls, *args, **kwargs):
+ kwargs["request"] = request
+ return ModelForm(*args, **kwargs)
+
+ return ModelFormMetaClass
+
+ def get_broadcaster_admin(self, instance):
+ return instance.broadcaster.name
+
+ get_broadcaster_admin.short_description = _("Broadcaster")
+
+ def is_auto_start_admin(self, instance):
+ return instance.is_auto_start
+
+ is_auto_start_admin.short_description = _("Auto start admin")
+ is_auto_start_admin.boolean = True
+
+ def get_thumbnail_admin(self, instance):
+ if instance.thumbnail and instance.thumbnail.file_exist():
+ im = get_thumbnail(
+ instance.thumbnail.file, "100x100", crop="center", quality=72
+ )
+ thumbnail_url = im.url
+ else:
+ thumbnail_url = static(DEFAULT_EVENT_THUMBNAIL)
+ return format_html(
+ '
'
+ % (
+ thumbnail_url,
+ instance.title.replace("{", "").replace("}", "").replace('"', "'"),
+ )
+ )
+
+ get_thumbnail_admin.short_description = _("Thumbnails")
+ get_thumbnail_admin.list_filter = True
+
+ form = EventAdminForm
+ list_display = [
+ "title",
+ "owner",
+ "start_date",
+ "start_time",
+ "end_time",
+ "get_broadcaster_admin",
+ "is_draft",
+ "is_restricted",
+ "password",
+ "is_auto_start_admin",
+ "get_thumbnail_admin",
+ ]
+ fields = [
+ "title",
+ "description",
+ "owner",
+ "additional_owners",
+ "start_date",
+ "start_time",
+ "end_time",
+ "type",
+ "broadcaster",
+ "iframe_url",
+ "iframe_height",
+ "aside_iframe_url",
+ "is_draft",
+ "password",
+ "is_restricted",
+ "restrict_access_to_groups",
+ "is_auto_start",
+ ]
+ search_fields = [
+ "title",
+ "broadcaster__name",
+ ]
+
+ list_filter = [
+ "start_date",
+ "is_draft",
+ "is_restricted",
+ "is_auto_start",
+ PasswordFilter,
+ ("broadcaster__building", admin.RelatedOnlyFieldListFilter),
+ ]
+
+ get_broadcaster_admin.admin_order_field = "broadcaster"
+ is_auto_start_admin.admin_order_field = "is_auto_start"
+
+ if FILEPICKER:
+ fields.append("thumbnail")
+
+ class Media:
+ css = {
+ "all": (
+ "css/pod.css",
+ "bootstrap-4/css/bootstrap.min.css",
+ "bootstrap-4/css/bootstrap-grid.css",
+ )
+ }
+ js = (
+ "podfile/js/filewidget.js",
+ "js/main.js",
+ "js/validate-date_delete-field.js",
+ "feather-icons/feather.min.js",
+ "bootstrap-4/js/bootstrap.min.js",
+ )
+
+
admin.site.register(Building, BuildingAdmin)
admin.site.register(Broadcaster, BroadcasterAdmin)
admin.site.register(HeartBeat, HeartBeatAdmin)
+admin.site.register(Event, EventAdmin)
diff --git a/pod/live/forms.py b/pod/live/forms.py
index 06170c39fb..74482cae0e 100644
--- a/pod/live/forms.py
+++ b/pod/live/forms.py
@@ -1,15 +1,26 @@
+from datetime import date, datetime
+
from django import forms
from django.conf import settings
-from pod.live.models import Building
-from pod.live.models import Broadcaster
-from pod.main.forms import add_placeholder_and_asterisk
+from django.contrib.admin import widgets
+from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
+from pod.live.models import (
+ Broadcaster,
+ get_building_having_available_broadcaster,
+ get_available_broadcasters_of_building,
+)
+from pod.live.models import Building, Event
+from pod.main.forms import add_placeholder_and_asterisk
+
FILEPICKER = False
if getattr(settings, "USE_PODFILE", False):
FILEPICKER = True
from pod.podfile.widgets import CustomFileWidget
+PILOTING_CHOICES = getattr(settings, "BROADCASTER_PILOTING_SOFTWARE", [])
+
class BuildingAdminForm(forms.ModelForm):
required_css_class = "required"
@@ -38,6 +49,17 @@ def __init__(self, *args, **kwargs):
if FILEPICKER:
self.fields["poster"].widget = CustomFileWidget(type="image")
+ impl_choices = [[None, ""]]
+ for val in PILOTING_CHOICES:
+ impl_choices.append([val, val])
+
+ self.fields["piloting_implementation"] = forms.ChoiceField(
+ choices=impl_choices,
+ required=False,
+ label=_("Piloting implementation"),
+ help_text=_("Select the piloting implementation for to this broadcaster."),
+ )
+
def clean(self):
super(BroadcasterAdminForm, self).clean()
@@ -46,9 +68,239 @@ class Meta(object):
fields = "__all__"
-class LivePasswordForm(forms.Form):
+class EventAdminForm(forms.ModelForm):
+ def __init__(self, *args, **kwargs):
+ self.request = kwargs.pop("request", None)
+ super(EventAdminForm, self).__init__(*args, **kwargs)
+ self.fields["owner"].initial = self.request.user
+ if FILEPICKER and self.fields.get("thumbnail"):
+ self.fields["thumbnail"].widget = CustomFileWidget(type="image")
+
+ def clean(self):
+ super(EventAdminForm, self).clean()
+ check_event_date_and_hour(self)
+
+ class Meta(object):
+ model = Event
+ fields = "__all__"
+ widgets = {
+ "start_time": forms.TimeInput(format="%H:%M"),
+ "end_time": forms.TimeInput(format="%H:%M"),
+ }
+
+
+class CustomBroadcasterChoiceField(forms.ModelChoiceField):
+ def label_from_instance(self, obj):
+ return obj.name
+
+
+def check_event_date_and_hour(form):
+ if (
+ not {"start_time", "start_time", "end_time", "broadcaster"}
+ <= form.cleaned_data.keys()
+ ):
+ return
+
+ d_deb = form.cleaned_data["start_date"]
+ h_deb = form.cleaned_data["start_time"]
+ h_fin = form.cleaned_data["end_time"]
+ brd = form.cleaned_data["broadcaster"]
+
+ if d_deb == date.today() and datetime.now().time() >= h_fin:
+ form.add_error("end_time", _("End should not be in the past"))
+ raise forms.ValidationError(_("An event cannot be planned in the past"))
+
+ if h_deb >= h_fin:
+ form.add_error("start_time", _("Start should not be after end"))
+ form.add_error("end_time", _("Start should not be after end"))
+ raise forms.ValidationError("Date error.")
+
+ events = Event.objects.filter(
+ Q(broadcaster_id=brd.id)
+ & Q(start_date=d_deb)
+ & (
+ (Q(start_time__lte=h_deb) & Q(end_time__gte=h_fin))
+ | (Q(start_time__gte=h_deb) & Q(end_time__lte=h_fin))
+ | (Q(start_time__lte=h_deb) & Q(end_time__gt=h_deb))
+ | (Q(start_time__lt=h_fin) & Q(end_time__gte=h_fin))
+ )
+ )
+ if form.instance.id:
+ events = events.exclude(id=form.instance.id)
+
+ if events.exists():
+ form.add_error("start_date", _("An event is already planned at these dates"))
+ raise forms.ValidationError("Date error.")
+
+
+class EventPasswordForm(forms.Form):
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput())
def __init__(self, *args, **kwargs):
- super(LivePasswordForm, self).__init__(*args, **kwargs)
+ super(EventPasswordForm, self).__init__(*args, **kwargs)
+ self.fields = add_placeholder_and_asterisk(self.fields)
+
+
+class EventForm(forms.ModelForm):
+
+ building = forms.ModelChoiceField(
+ label=_("Building"),
+ queryset=Building.objects.none(),
+ to_field_name="name",
+ empty_label=None,
+ )
+
+ broadcaster = CustomBroadcasterChoiceField(
+ label=_("Broadcaster device"),
+ queryset=Broadcaster.objects.none(),
+ empty_label=None,
+ )
+
+ def __init__(self, *args, **kwargs):
+ self.user = kwargs.pop("user", None)
+ is_current_event = kwargs.pop("is_current_event", None)
+ broadcaster_id = kwargs.pop("broadcaster_id", None)
+ building_id = kwargs.pop("building_id", None)
+ super(EventForm, self).__init__(*args, **kwargs)
+ self.auto_id = "event_%s"
+ self.fields["owner"].initial = self.user
+ # Manage required fields html
+ self.fields = add_placeholder_and_asterisk(self.fields)
+ # Manage fields to display
+ self.initFields(is_current_event)
+
+ # mise a jour dynamique de la liste des diffuseurs
+ if "building" in self.data:
+ self.saving()
+ return
+
+ if self.instance.pk and not is_current_event:
+ self.editing()
+ return
+
+ if not self.instance.pk:
+ # à la création
+ self.creating(broadcaster_id, building_id)
+
+ def creating(self, broadcaster_id, building_id):
+ if broadcaster_id is not None and building_id is not None:
+ query_buildings = get_building_having_available_broadcaster(
+ self.user, building_id
+ )
+ self.fields["building"].queryset = query_buildings.all()
+ self.initial["building"] = (
+ Building.objects.filter(Q(id=building_id)).first().name
+ )
+ query_broadcaster = get_available_broadcasters_of_building(
+ self.user, building_id, broadcaster_id
+ )
+ self.fields["broadcaster"].queryset = query_broadcaster.all()
+ self.initial["broadcaster"] = broadcaster_id
+ else:
+ query_buildings = get_building_having_available_broadcaster(self.user)
+ if query_buildings:
+ self.fields["building"].queryset = query_buildings.all()
+ self.initial["building"] = query_buildings.first().name
+ self.fields[
+ "broadcaster"
+ ].queryset = get_available_broadcasters_of_building(
+ self.user, query_buildings.first()
+ )
+
+ def editing(self):
+ broadcaster = self.instance.broadcaster
+ self.fields["broadcaster"].queryset = get_available_broadcasters_of_building(
+ self.user, broadcaster.building.id, broadcaster.id
+ )
+ self.fields["building"].queryset = get_building_having_available_broadcaster(
+ self.user, broadcaster.building.id
+ )
+ self.initial["building"] = broadcaster.building.name
+
+ def saving(self):
+ try:
+ build = Building.objects.filter(name=self.data.get("building")).first()
+ self.fields["broadcaster"].queryset = get_available_broadcasters_of_building(
+ self.user, build.id
+ )
+ self.fields["building"].queryset = get_building_having_available_broadcaster(
+ self.user, build.id
+ )
+ self.initial["building"] = build.name
+ except (ValueError, TypeError):
+ pass # invalid input from the client; ignore and fallback to empty Broadcaster queryset
+
+ def initFields(self, is_current_event):
+ if not self.user.is_superuser:
+ self.remove_field("owner")
+ self.instance.owner = self.user
+ if is_current_event:
+ self.remove_field("start_date")
+ self.remove_field("start_time")
+ self.remove_field("is_draft")
+ self.remove_field("is_auto_start")
+ self.remove_field("password")
+ self.remove_field("is_restricted")
+ self.remove_field("restrict_access_to_groups")
+ self.remove_field("type")
+ self.remove_field("description")
+ self.remove_field("building")
+ self.remove_field("broadcaster")
+ self.remove_field("owner")
+ self.remove_field("thumbnail")
+
+ def remove_field(self, field):
+ if self.fields.get(field):
+ del self.fields[field]
+
+ def clean(self):
+ check_event_date_and_hour(self)
+ cleaned_data = super(EventForm, self).clean()
+
+ if cleaned_data.get("is_draft", False):
+ cleaned_data["password"] = None
+ cleaned_data["is_restricted"] = False
+ cleaned_data["restrict_access_to_groups"] = []
+
+ class Meta(object):
+ model = Event
+ fields = [
+ "title",
+ "description",
+ "owner",
+ "additional_owners",
+ "start_date",
+ "start_time",
+ "end_time",
+ "building",
+ "broadcaster",
+ "type",
+ "is_draft",
+ "password",
+ "is_restricted",
+ "restrict_access_to_groups",
+ "is_auto_start",
+ "iframe_url",
+ "iframe_height",
+ "aside_iframe_url",
+ ]
+ widgets = {
+ "start_date": widgets.AdminDateWidget,
+ "start_time": forms.TimeInput(format="%H:%M", attrs={"class": "vTimeField"}),
+ "end_time": forms.TimeInput(format="%H:%M", attrs={"class": "vTimeField"}),
+ }
+ if FILEPICKER:
+ fields.append("thumbnail")
+ widgets["thumbnail"] = CustomFileWidget(type="image")
+
+
+class EventDeleteForm(forms.Form):
+ agree = forms.BooleanField(
+ label=_("I agree"),
+ help_text=_("Delete event cannot be undo"),
+ widget=forms.CheckboxInput(),
+ )
+
+ def __init__(self, *args, **kwargs):
+ super(EventDeleteForm, self).__init__(*args, **kwargs)
self.fields = add_placeholder_and_asterisk(self.fields)
diff --git a/pod/live/management/commands/checkLiveStartStop.py b/pod/live/management/commands/checkLiveStartStop.py
new file mode 100644
index 0000000000..d6354fc1a0
--- /dev/null
+++ b/pod/live/management/commands/checkLiveStartStop.py
@@ -0,0 +1,104 @@
+import json
+from datetime import date, datetime
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+from django.db.models import Q
+
+from pod.live.models import Event
+from pod.live.views import (
+ is_recording,
+ event_stoprecord,
+ event_startrecord,
+)
+
+DEFAULT_EVENT_PATH = getattr(settings, "DEFAULT_EVENT_PATH", "")
+DEBUG = getattr(settings, "DEBUG", "")
+
+
+class Command(BaseCommand):
+ help = "start or stop broadcaster recording based on live events "
+
+ debug_mode = DEBUG
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "-f",
+ "--force",
+ action="store_true",
+ help="Start and stop recording FOR REAL",
+ )
+
+ def handle(self, *args, **options):
+
+ if options["force"]:
+ self.debug_mode = False
+
+ self.stdout.write(
+ f"- Beginning at {datetime.now().strftime('%H:%M:%S')}", ending=""
+ )
+ self.stdout.write(" - IN DEBUG MODE -" if self.debug_mode else "")
+
+ self.stop_finished()
+
+ self.start_new()
+
+ self.stdout.write("- End -")
+
+ def stop_finished(self):
+ self.stdout.write("-- Stopping finished events (if started with Pod) :")
+
+ now = datetime.now().replace(second=0, microsecond=0)
+
+ # events ending now
+ events = Event.objects.filter(Q(start_date=date.today()) & Q(end_time=now))
+
+ for event in events:
+ if not is_recording(event.broadcaster, True):
+ continue
+
+ self.stdout.write(
+ f"Broadcaster {event.broadcaster.name} should be stopped : ", ending=""
+ )
+
+ if self.debug_mode:
+ self.stdout.write("... but not tried (debug mode) ")
+ continue
+
+ response = event_stoprecord(event.id, event.broadcaster.id)
+ if json.loads(response.content)["success"]:
+ self.stdout.write(" ... stopped ")
+ else:
+ self.stderr.write(" ... fail to stop recording")
+
+ def start_new(self):
+
+ self.stdout.write("-- Starting new events :")
+
+ events = Event.objects.filter(
+ Q(is_auto_start=True)
+ & Q(start_date=date.today())
+ & Q(start_time__lte=datetime.now())
+ & Q(end_time__gt=datetime.now())
+ )
+
+ for event in events:
+
+ if is_recording(event.broadcaster):
+ self.stdout.write(
+ f"Broadcaster {event.broadcaster.name} is already recording"
+ )
+ continue
+
+ self.stdout.write(
+ f"Broadcaster {event.broadcaster.name} should be started : ", ending=""
+ )
+
+ if self.debug_mode:
+ self.stdout.write("... but not tried (debug mode) ")
+ continue
+
+ if event_startrecord(event.id, event.broadcaster.id):
+ self.stdout.write(" ... successfully started")
+ else:
+ self.stderr.write(" ... fail to start")
diff --git a/pod/live/models.py b/pod/live/models.py
index cc2ad564a2..298b308183 100644
--- a/pod/live/models.py
+++ b/pod/live/models.py
@@ -1,19 +1,30 @@
"""Esup-Pod "live" models."""
+import hashlib
+from datetime import date, datetime
-from django.db import models
-from django.conf import settings
-from django.utils.translation import ugettext_lazy as _
from ckeditor.fields import RichTextField
-from django.template.defaultfilters import slugify
-from pod.video.models import Video
+from django.conf import settings
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import User
from django.contrib.sites.models import Site
-from select2 import fields as select2_fields
-from django.dispatch import receiver
+from django.contrib.sites.shortcuts import get_current_site
+from django.contrib.staticfiles.templatetags.staticfiles import static
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.db.models import Q
from django.db.models.signals import post_save
+from django.dispatch import receiver
+from django.template.defaultfilters import slugify
from django.urls import reverse
-from django.contrib.auth.models import User
from django.utils import timezone
+from django.utils.html import format_html
+from django.utils.translation import ugettext_lazy as _
+from select2 import fields as select2_fields
+from sorl.thumbnail import get_thumbnail
+from pod.authentication.models import AccessGroup
+from pod.main.models import get_nextautoincrement
+from pod.video.models import Video, Type
if getattr(settings, "USE_PODFILE", False):
from pod.podfile.models import CustomImageModel
@@ -24,6 +35,14 @@
from pod.main.models import CustomImageModel
DEFAULT_THUMBNAIL = getattr(settings, "DEFAULT_THUMBNAIL", "img/default.svg")
+DEFAULT_EVENT_THUMBNAIL = getattr(
+ settings, "DEFAULT_EVENT_THUMBNAIL", "img/default-event.svg"
+)
+DEFAULT_EVENT_TYPE_ID = getattr(settings, "DEFAULT_EVENT_TYPE_ID", 1)
+AFFILIATION_EVENT = getattr(
+ settings, "AFFILIATION_EVENT", ("faculty", "employee", "staff")
+)
+SECRET_KEY = getattr(settings, "SECRET_KEY", "")
class Building(models.Model):
@@ -52,12 +71,7 @@ class Meta:
verbose_name = _("Building")
verbose_name_plural = _("Buildings")
ordering = ["name"]
- permissions = (
- (
- "view_building_supervisor",
- "Can see the supervisor page for building",
- ),
- )
+ permissions = (("acces_live_pages", "Access to all live pages"),)
@receiver(post_save, sender=Building)
@@ -66,6 +80,40 @@ def default_site_building(sender, instance, created, **kwargs):
instance.sites.add(Site.objects.get_current())
+def get_available_broadcasters_of_building(user, building_id, broadcaster_id=None):
+ right_filter = Broadcaster.objects.filter(
+ Q(status=True)
+ & Q(building_id=building_id)
+ & (Q(manage_groups__isnull=True) | Q(manage_groups__in=user.groups.all()))
+ )
+ if broadcaster_id:
+ return (
+ (right_filter | Broadcaster.objects.filter(Q(id=broadcaster_id)))
+ .distinct()
+ .order_by("name")
+ )
+
+ return right_filter.distinct().order_by("name")
+
+
+def get_building_having_available_broadcaster(user, building_id=None):
+ right_filter = Building.objects.filter(
+ Q(broadcaster__status=True)
+ & (
+ Q(broadcaster__manage_groups__isnull=True)
+ | Q(broadcaster__manage_groups__in=user.groups.all())
+ )
+ )
+ if building_id:
+ return (
+ (right_filter | Building.objects.filter(Q(id=building_id)))
+ .distinct()
+ .order_by("name")
+ )
+
+ return right_filter.distinct().order_by("name")
+
+
class Broadcaster(models.Model):
name = models.CharField(_("name"), max_length=200, unique=True)
slug = models.SlugField(
@@ -96,24 +144,6 @@ class Broadcaster(models.Model):
null=True,
verbose_name=_("Video on hold"),
)
- iframe_url = models.URLField(
- _("Embedded Site URL"),
- help_text=_("Url of the embedded site to display"),
- null=True,
- blank=True,
- )
- iframe_height = models.IntegerField(
- _("Embedded Site Height"),
- null=True,
- blank=True,
- help_text=_("Height of the embedded site (in pixels)"),
- )
- aside_iframe_url = models.URLField(
- _("Embedded aside Site URL"),
- help_text=_("Url of the embedded site to display on aside"),
- null=True,
- blank=True,
- )
status = models.BooleanField(
default=0,
help_text=_("Check if the broadcaster is currently sending stream."),
@@ -133,18 +163,36 @@ class Broadcaster(models.Model):
help_text=_("Live is accessible from the Live tab"),
default=True,
)
- password = models.CharField(
- _("password"),
- help_text=_("Viewing this live will not be possible without this password."),
- max_length=50,
+ viewcount = models.IntegerField(_("Number of viewers"), default=0, editable=False)
+ viewers = models.ManyToManyField(User, editable=False)
+
+ manage_groups = select2_fields.ManyToManyField(
+ Group,
+ blank=True,
+ verbose_name=_("Groups"),
+ help_text=_(
+ "Select one or more groups who can manage event to this broadcaster."
+ ),
+ related_name="managegroups",
+ )
+
+ piloting_implementation = models.CharField(
+ max_length=100,
blank=True,
null=True,
+ verbose_name=_("Piloting implementation"),
+ help_text=_("Select the piloting implementation for to this broadcaster."),
+ )
+
+ piloting_conf = models.TextField(
+ null=True,
+ blank=True,
+ verbose_name=_("Piloting configuration parameters"),
+ help_text=_("Add piloting configuration parameters in Json format."),
)
- viewcount = models.IntegerField(_("Number of viewers"), default=0, editable=False)
- viewers = models.ManyToManyField(User, editable=False)
def get_absolute_url(self):
- return reverse("live:video_live", args=[str(self.slug)])
+ return reverse("live:direct", args=[str(self.slug)])
def __str__(self):
return "%s - %s" % (self.name, self.url)
@@ -169,6 +217,32 @@ class Meta:
def sites(self):
return self.building.sites
+ def check_recording(self):
+ from pod.live.pilotingInterface import get_piloting_implementation
+
+ impl = get_piloting_implementation(self)
+ if impl:
+ return impl.is_recording()
+ else:
+ return False
+
+ def is_recording(self):
+ try:
+ return self.check_recording()
+ except Exception:
+ return False
+
+ def is_recording_admin(self):
+ try:
+ if self.check_recording():
+ return format_html('
')
+ else:
+ return format_html('
')
+ except Exception:
+ return format_html('
')
+
+ is_recording_admin.short_description = _("Is recording ?")
+
class HeartBeat(models.Model):
user = models.ForeignKey(User, null=True, verbose_name=_("Viewer"))
@@ -182,3 +256,269 @@ class Meta:
verbose_name = _("Heartbeat")
verbose_name_plural = _("Heartbeats")
ordering = ["broadcaster"]
+
+
+def current_time():
+ return datetime.now().replace(second=0, microsecond=0)
+
+
+def one_hour_hence():
+ return current_time() + timezone.timedelta(hours=1)
+
+
+def get_default_event_type():
+ return DEFAULT_EVENT_TYPE_ID
+
+
+def present_or_future_date(value):
+ if value < date.today():
+ raise ValidationError(_("An event cannot be planned in the past"))
+ return value
+
+
+def select_event_owner():
+ return (
+ lambda q: (Q(first_name__icontains=q) | Q(last_name__icontains=q))
+ & Q(owner__sites=Site.objects.get_current())
+ & Q(owner__accessgroups__code_name__in=AFFILIATION_EVENT)
+ )
+
+
+class Event(models.Model):
+ slug = models.SlugField(
+ _("Slug"),
+ unique=True,
+ max_length=255,
+ editable=False,
+ )
+
+ title = models.CharField(
+ _("Title"),
+ max_length=250,
+ help_text=_(
+ "Please choose a title as short and accurate as "
+ "possible, reflecting the main subject / context "
+ "of the content. (max length: 250 characters)"
+ ),
+ )
+
+ description = RichTextField(
+ _("Description"),
+ config_name="complete",
+ blank=True,
+ help_text=_(
+ "In this field you can describe your content, "
+ "add all needed related information, and "
+ "format the result using the toolbar."
+ ),
+ )
+
+ owner = select2_fields.ForeignKey(
+ User,
+ ajax=True,
+ verbose_name=_("Owner"),
+ search_field=select_event_owner(),
+ on_delete=models.CASCADE,
+ )
+
+ additional_owners = select2_fields.ManyToManyField(
+ User,
+ blank=True,
+ ajax=True,
+ js_options={"width": "off"},
+ verbose_name=_("Additional owners"),
+ search_field=select_event_owner(),
+ related_name="owners_events",
+ help_text=_(
+ "You can add additional owners to the event. They "
+ "will have the same rights as you except that they "
+ "can't delete this event."
+ ),
+ )
+
+ start_date = models.DateField(
+ _("Date of event"),
+ default=date.today,
+ help_text=_("Start date of the live."),
+ validators=[present_or_future_date],
+ )
+ start_time = models.TimeField(
+ _("Start time"),
+ default=current_time,
+ help_text=_("Start time of the live event."),
+ )
+ end_time = models.TimeField(
+ _("End time"),
+ default=one_hour_hence,
+ help_text=_("End time of the live event."),
+ )
+
+ broadcaster = models.ForeignKey(
+ Broadcaster,
+ verbose_name=_("Broadcaster"),
+ help_text=_("Broadcaster name."),
+ )
+
+ type = models.ForeignKey(Type, default=DEFAULT_EVENT_TYPE_ID, verbose_name=_("Type"))
+
+ iframe_url = models.URLField(
+ _("Embedded Site URL"),
+ help_text=_("Url of the embedded site to display"),
+ null=True,
+ blank=True,
+ )
+ iframe_height = models.IntegerField(
+ _("Embedded Site Height"),
+ null=True,
+ blank=True,
+ help_text=_("Height of the embedded site (in pixels)"),
+ )
+ aside_iframe_url = models.URLField(
+ _("Embedded aside Site URL"),
+ help_text=_("Url of the embedded site to display on aside"),
+ null=True,
+ blank=True,
+ )
+
+ is_draft = models.BooleanField(
+ verbose_name=_("Draft"),
+ help_text=_(
+ "If this box is checked, the event will be visible "
+ "only by you and the additional owners "
+ "but accessible to anyone having the url link."
+ ),
+ default=True,
+ )
+ is_restricted = models.BooleanField(
+ verbose_name=_("Restricted access"),
+ help_text=_(
+ "If this box is checked, "
+ "the event will only be accessible to authenticated users."
+ ),
+ default=False,
+ )
+
+ restrict_access_to_groups = select2_fields.ManyToManyField(
+ AccessGroup,
+ blank=True,
+ verbose_name=_("Groups"),
+ help_text=_("Select one or more groups who can access to this event"),
+ )
+
+ is_auto_start = models.BooleanField(
+ verbose_name=_("Auto start"),
+ help_text=_("If this box is checked, " "the record will start automatically."),
+ default=False,
+ )
+
+ thumbnail = models.ForeignKey(
+ CustomImageModel,
+ models.SET_NULL,
+ blank=True,
+ null=True,
+ verbose_name=_("Thumbnails"),
+ )
+
+ password = models.CharField(
+ _("password"),
+ help_text=_("Viewing this event will not be possible without this password."),
+ max_length=50,
+ blank=True,
+ null=True,
+ )
+
+ videos = models.ManyToManyField(
+ Video,
+ editable=False,
+ )
+
+ class Meta:
+ verbose_name = _("Event")
+ verbose_name_plural = _("Events")
+ ordering = ["start_date", "start_time"]
+
+ def save(self, *args, **kwargs):
+ if not self.id:
+ try:
+ new_id = get_nextautoincrement(Event)
+ except Exception:
+ try:
+ new_id = Event.objects.latest("id").id
+ new_id += 1
+ except Exception:
+ new_id = 1
+ else:
+ new_id = self.id
+ new_id = "%04d" % new_id
+ self.slug = "%s-%s" % (new_id, slugify(self.title))
+ super(Event, self).save(*args, **kwargs)
+
+ def __str__(self):
+ if self.id:
+ return "%s - %s" % ("%04d" % self.id, self.title)
+ else:
+ return "None"
+
+ def get_absolute_url(self):
+ return reverse("live:event", args=[str(self.slug)])
+
+ def get_full_url(self, request=None):
+ """Get the video full URL."""
+ full_url = "".join(
+ ["//", get_current_site(request).domain, self.get_absolute_url()]
+ )
+ return full_url
+
+ def get_hashkey(self):
+ return hashlib.sha256(
+ ("%s-%s" % (SECRET_KEY, self.id)).encode("utf-8")
+ ).hexdigest()
+
+ def get_thumbnail_url(self):
+ """Get a thumbnail url for the event."""
+ request = None
+ if self.thumbnail and self.thumbnail.file_exist():
+ thumbnail_url = "".join(
+ [
+ "//",
+ get_current_site(request).domain,
+ self.thumbnail.file.url,
+ ]
+ )
+ else:
+ thumbnail_url = static(DEFAULT_EVENT_THUMBNAIL)
+ return thumbnail_url
+
+ def get_thumbnail_card(self):
+ """Return thumbnail image card of current event."""
+ if self.thumbnail and self.thumbnail.file_exist():
+ im = get_thumbnail(self.thumbnail.file, "x170", crop="center", quality=72)
+ thumbnail_url = im.url
+ else:
+ thumbnail_url = static(DEFAULT_EVENT_THUMBNAIL)
+ return (
+ '
'
+ % thumbnail_url
+ )
+
+ def is_current(self):
+ return self.start_date == date.today() and (
+ self.start_time <= datetime.now().time() <= self.end_time
+ )
+
+ def is_past(self):
+ return self.start_date < date.today() or (
+ self.start_date == date.today() and self.end_time < datetime.now().time()
+ )
+
+ def is_coming(self):
+ return self.start_date > date.today() or (
+ self.start_date == date.today() and datetime.now().time() < self.start_time
+ )
+
+ def get_start(self):
+ return datetime.combine(self.start_date, self.start_time)
+
+ def get_end(self):
+ return datetime.combine(self.start_date, self.end_time)
diff --git a/pod/live/pilotingInterface.py b/pod/live/pilotingInterface.py
new file mode 100644
index 0000000000..309789250c
--- /dev/null
+++ b/pod/live/pilotingInterface.py
@@ -0,0 +1,346 @@
+import http
+import json
+import logging
+import os
+import re
+from abc import ABC, abstractmethod
+from typing import Optional
+
+import requests
+from django.conf import settings
+
+from .models import Broadcaster
+
+EXISTING_BROADCASTER_IMPLEMENTATIONS = ["Wowza"]
+DEFAULT_EVENT_PATH = getattr(settings, "DEFAULT_EVENT_PATH", "")
+
+logger = logging.getLogger("pod.live")
+
+
+class PilotingInterface(ABC):
+ @abstractmethod
+ def __init__(self, broadcaster: Broadcaster):
+ """Initialize the PilotingInterface
+ :param broadcaster: the broadcaster to pilot"""
+ self.broadcaster = broadcaster
+ raise NotImplementedError
+
+ @abstractmethod
+ def check_piloting_conf(self) -> bool:
+ """Checks the piloting conf value"""
+ raise NotImplementedError
+
+ @abstractmethod
+ def is_available_to_record(self) -> bool:
+ """Checks if the broadcaster is available"""
+ raise NotImplementedError
+
+ @abstractmethod
+ def is_recording(self, with_file_check=False) -> bool:
+ """Checks if the broadcaster is being recorded
+ :param with_file_check: checks if tmp recording file is present on the filesystem (recording could have been launch from somewhere else)
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def start(self, event_id, login=None) -> bool:
+ """Start the recording"""
+ raise NotImplementedError
+
+ @abstractmethod
+ def split(self) -> bool:
+ """Split the current record"""
+ raise NotImplementedError
+
+ @abstractmethod
+ def stop(self) -> bool:
+ """Stop the recording"""
+ raise NotImplementedError
+
+ @abstractmethod
+ def get_info_current_record(self) -> dict:
+ """Get info of current record"""
+ raise NotImplementedError
+
+
+def get_piloting_implementation(broadcaster) -> Optional[PilotingInterface]:
+ logger.debug("get_piloting_implementation")
+ piloting_impl = broadcaster.piloting_implementation
+ if not piloting_impl:
+ logger.info(
+ "'piloting_implementation' value is not set for '"
+ + broadcaster.name
+ + "' broadcaster."
+ )
+ return None
+
+ if not piloting_impl.lower() in map(str.lower, EXISTING_BROADCASTER_IMPLEMENTATIONS):
+ logger.warning(
+ "'piloting_implementation' : "
+ + piloting_impl
+ + " is not know for '"
+ + broadcaster.name
+ + "' broadcaster. Available piloting_implementations are '"
+ + "','".join(EXISTING_BROADCASTER_IMPLEMENTATIONS)
+ + "'"
+ )
+ return None
+
+ if piloting_impl.lower() == "wowza":
+ logger.debug(
+ "'piloting_implementation' found : "
+ + piloting_impl.lower()
+ + " for '"
+ + broadcaster.name
+ + "' broadcaster."
+ )
+ return Wowza(broadcaster)
+
+ logger.warning("->get_piloting_implementation - This should not happen.")
+ return None
+
+
+def is_recording_launched_by_pod(self) -> bool:
+ # Récupération du fichier associé à l'enregistrement du broadcaster
+ current_record_info = self.get_info_current_record()
+ if not current_record_info.get("currentFile"):
+ logging.error(" ... impossible to get recording file name")
+ return False
+
+ filename = current_record_info.get("currentFile")
+ full_file_name = os.path.join(DEFAULT_EVENT_PATH, filename)
+
+ # Vérification qu'il existe bien pour cette instance ce Pod
+ if not os.path.exists(full_file_name):
+ logging.debug(" ... is not on this POD recording filesystem : " + full_file_name)
+ return False
+
+ return True
+
+
+class Wowza(PilotingInterface):
+ def __init__(self, broadcaster: Broadcaster):
+ self.broadcaster = broadcaster
+ self.url = None
+ if self.check_piloting_conf():
+ conf = json.loads(self.broadcaster.piloting_conf)
+ self.url = "{server_url}/v2/servers/_defaultServer_/vhosts/_defaultVHost_/applications/{application}".format(
+ server_url=conf["server_url"],
+ application=conf["application"],
+ )
+
+ def check_piloting_conf(self) -> bool:
+ logging.debug("Wowza - Check piloting conf")
+ conf = self.broadcaster.piloting_conf
+ if not conf:
+ logging.error(
+ "'piloting_conf' value is not set for '"
+ + self.broadcaster.name
+ + "' broadcaster."
+ )
+ return False
+ try:
+ decoded = json.loads(conf)
+ except Exception:
+ logging.error(
+ "'piloting_conf' has not a valid Json format for '"
+ + self.broadcaster.name
+ + "' broadcaster."
+ )
+ return False
+ if not {"server_url", "application", "livestream"} <= decoded.keys():
+ logging.error(
+ "'piloting_conf' format value for '"
+ + self.broadcaster.name
+ + "' broadcaster must be like : "
+ "{'server_url':'...','application':'...','livestream':'...'}"
+ )
+ return False
+
+ logging.debug("->piloting conf OK")
+ return True
+
+ def is_available_to_record(self) -> bool:
+ logging.debug("Wowza - Check availability")
+ json_conf = self.broadcaster.piloting_conf
+ conf = json.loads(json_conf)
+ url_state_live_stream_recording = (
+ self.url + "/instances/_definst_/incomingstreams/" + conf["livestream"]
+ )
+
+ response = requests.get(
+ url_state_live_stream_recording,
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
+ )
+
+ if response.status_code == http.HTTPStatus.OK:
+ if (
+ response.json().get("isConnected") is True
+ and response.json().get("isRecordingSet") is False
+ ):
+ return True
+
+ return False
+
+ def is_recording(self, with_file_check=False) -> bool:
+ logging.debug("Wowza - Check if is being recorded")
+ json_conf = self.broadcaster.piloting_conf
+ conf = json.loads(json_conf)
+ url_state_live_stream_recording = (
+ self.url + "/instances/_definst_/incomingstreams/" + conf["livestream"]
+ )
+
+ response = requests.get(
+ url_state_live_stream_recording,
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
+ )
+
+ if (
+ response.status_code != http.HTTPStatus.OK
+ or not response.json().get("isConnected")
+ or not response.json().get("isRecordingSet")
+ ):
+ return False
+
+ if with_file_check:
+ return is_recording_launched_by_pod(self)
+ else:
+ return True
+
+ def start(self, event_id=None, login=None) -> bool:
+ logging.debug("Wowza - Start record")
+ json_conf = self.broadcaster.piloting_conf
+ conf = json.loads(json_conf)
+ url_start_record = (
+ self.url + "/instances/_definst_/streamrecorders/" + conf["livestream"]
+ )
+ filename = self.broadcaster.slug
+ if event_id is not None:
+ filename = str(event_id) + "_" + filename
+ elif login is not None:
+ filename = login + "_" + filename
+ data = {
+ "instanceName": "",
+ "fileVersionDelegateName": "",
+ "serverName": "",
+ "recorderName": "",
+ "currentSize": 0,
+ "segmentSchedule": "",
+ "startOnKeyFrame": True,
+ "outputPath": DEFAULT_EVENT_PATH,
+ "baseFile": filename + "_${RecordingStartTime}_${SegmentNumber}",
+ "currentFile": "",
+ "saveFieldList": [""],
+ "recordData": False,
+ "applicationName": "",
+ "moveFirstVideoFrameToZero": False,
+ "recorderErrorString": "",
+ "segmentSize": 0,
+ "defaultRecorder": False,
+ "splitOnTcDiscontinuity": False,
+ "version": "",
+ "segmentDuration": 0,
+ "recordingStartTime": "",
+ "fileTemplate": "",
+ "backBufferTime": 0,
+ "segmentationType": "",
+ "currentDuration": 0,
+ "fileFormat": "",
+ "recorderState": "",
+ "option": "",
+ }
+
+ response = requests.post(
+ url_start_record,
+ json=data,
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
+ )
+
+ if response.status_code == http.HTTPStatus.CREATED:
+ if response.json().get("success"):
+ return True
+
+ return False
+
+ def split(self) -> bool:
+ logging.debug("Wowza - Split record")
+ json_conf = self.broadcaster.piloting_conf
+ conf = json.loads(json_conf)
+ url_split_record = (
+ self.url
+ + "/instances/_definst_/streamrecorders/"
+ + conf["livestream"]
+ + "/actions/splitRecording"
+ )
+ response = requests.put(
+ url_split_record,
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
+ )
+
+ if response.status_code == http.HTTPStatus.OK:
+ if response.json().get("success"):
+ return True
+
+ return False
+
+ def stop(self) -> bool:
+ logging.debug("Wowza - Stop_record")
+ json_conf = self.broadcaster.piloting_conf
+ conf = json.loads(json_conf)
+ url_stop_record = (
+ self.url
+ + "/instances/_definst_/streamrecorders/"
+ + conf["livestream"]
+ + "/actions/stopRecording"
+ )
+ response = requests.put(
+ url_stop_record,
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
+ )
+
+ if response.status_code == http.HTTPStatus.OK:
+ if response.json().get("success"):
+ return True
+
+ return False
+
+ def get_info_current_record(self):
+ logging.debug("Wowza - Get info from current record")
+ json_conf = self.broadcaster.piloting_conf
+ conf = json.loads(json_conf)
+ url_state_live_stream_recording = (
+ self.url + "/instances/_definst_/streamrecorders/" + conf["livestream"]
+ )
+
+ response = requests.get(
+ url_state_live_stream_recording,
+ verify=True,
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
+ )
+
+ if response.status_code != http.HTTPStatus.OK:
+ return {
+ "currentFile": "",
+ "segmentNumber": "",
+ "outputPath": "",
+ "segmentDuration": "",
+ }
+
+ segment_number = ""
+ current_file = response.json().get("currentFile")
+
+ try:
+ ending = current_file.split("_")[-1]
+ if re.match(r"\d+\.", ending):
+ number = ending.split(".")[0]
+ if int(number) > 0:
+ segment_number = number
+ except Exception:
+ pass
+
+ return {
+ "currentFile": current_file,
+ "segmentNumber": segment_number,
+ "outputPath": response.json().get("outputPath"),
+ "segmentDuration": response.json().get("segmentDuration"),
+ }
diff --git a/pod/live/rest_urls.py b/pod/live/rest_urls.py
index 8ff016a442..1820ce565e 100644
--- a/pod/live/rest_urls.py
+++ b/pod/live/rest_urls.py
@@ -1,6 +1,7 @@
-from .rest_views import BuildingViewSet, BroadcasterViewSet
+from .rest_views import BuildingViewSet, BroadcasterViewSet, EventViewSet
def add_register(router):
router.register(r"buildings", BuildingViewSet)
router.register(r"broadcasters", BroadcasterViewSet)
+ router.register(r"events", EventViewSet)
diff --git a/pod/live/rest_views.py b/pod/live/rest_views.py
index f727ba39f3..23989d7476 100644
--- a/pod/live/rest_views.py
+++ b/pod/live/rest_views.py
@@ -1,4 +1,4 @@
-from .models import Building, Broadcaster
+from .models import Building, Broadcaster, Event
from rest_framework import serializers, viewsets
# Serializers define the API representation.
@@ -11,6 +11,11 @@ class Meta:
class BroadcasterSerializer(serializers.HyperlinkedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name="broadcaster-detail", lookup_field="slug"
+ )
+ broadcaster_url = serializers.URLField(source="url")
+
class Meta:
model = Broadcaster
fields = (
@@ -21,13 +26,48 @@ class Meta:
"building",
"description",
"poster",
- "url",
+ "broadcaster_url",
"status",
"is_restricted",
+ "manage_groups",
+ "piloting_implementation",
+ "piloting_conf",
)
lookup_field = "slug"
+class EventSerializer(serializers.HyperlinkedModelSerializer):
+ broadcaster = serializers.HyperlinkedRelatedField(
+ view_name="broadcaster-detail",
+ queryset=Broadcaster.objects.all(),
+ many=False,
+ read_only=False,
+ lookup_field="slug",
+ )
+
+ class Meta:
+ model = Event
+ fields = (
+ "id",
+ "url",
+ "title",
+ "owner",
+ "additional_owners",
+ "slug",
+ "description",
+ "start_date",
+ "start_time",
+ "end_time",
+ "broadcaster",
+ "type",
+ "is_draft",
+ "is_restricted",
+ "is_auto_start",
+ "videos",
+ "thumbnail",
+ )
+
+
#############################################################################
# ViewSets define the view behavior.
#############################################################################
@@ -42,3 +82,8 @@ class BroadcasterViewSet(viewsets.ModelViewSet):
queryset = Broadcaster.objects.all().order_by("building", "name")
serializer_class = BroadcasterSerializer
lookup_field = "slug"
+
+
+class EventViewSet(viewsets.ModelViewSet):
+ queryset = Event.objects.all().order_by("start_date", "start_time")
+ serializer_class = EventSerializer
diff --git a/pod/live/static/css/event.css b/pod/live/static/css/event.css
new file mode 100644
index 0000000000..b2db2a8467
--- /dev/null
+++ b/pod/live/static/css/event.css
@@ -0,0 +1,3 @@
+.current_event_feather{
+ stroke: red;
+}
\ No newline at end of file
diff --git a/pod/live/static/img/default-event.svg b/pod/live/static/img/default-event.svg
new file mode 100644
index 0000000000..b96e03308c
--- /dev/null
+++ b/pod/live/static/img/default-event.svg
@@ -0,0 +1,36 @@
+
+
+
diff --git a/pod/live/static/js/broadcaster_from_building.js b/pod/live/static/js/broadcaster_from_building.js
new file mode 100644
index 0000000000..6cf1f80ef7
--- /dev/null
+++ b/pod/live/static/js/broadcaster_from_building.js
@@ -0,0 +1,95 @@
+$(document).ready(function () {
+ let broadcastField = $("#event_broadcaster");
+ let restrictedCheckBox = $("#event_is_restricted");
+ let restrictedHelp = $("#event_is_restrictedHelp");
+ let restrictedLabel = $(".field_is_restricted");
+
+ let change_restriction = (restrict) => {
+ if (restrict === true) {
+ restrictedCheckBox.prop("checked", true);
+ restrictedCheckBox.attr("onclick", "return false");
+ restrictedHelp.html(
+ gettext("Restricted because the broadcaster is restricted")
+ );
+ restrictedLabel.css("opacity", "0.5");
+ } else {
+ restrictedCheckBox.removeAttr("onclick");
+ restrictedHelp.html(
+ gettext(
+ "If this box is checked, the event will only be accessible to authenticated users."
+ )
+ );
+ restrictedLabel.css("opacity", "");
+ }
+ };
+
+ let getBroadcasterRestriction = () => {
+ let broadcaster_id = broadcastField.find(":selected").val();
+ if (typeof broadcaster_id === "undefined" || broadcaster_id === "") return;
+
+ $.ajax({
+ url: "/live/ajax_calls/getbroadcasterrestriction/",
+ type: "GET",
+ dataType: "JSON",
+ cache: false,
+ data: {
+ idbroadcaster: broadcaster_id,
+ },
+ success: (s) => {
+ change_restriction(s.restricted);
+ },
+ error: () => {
+ change_restriction(false);
+ alert(gettext("an error occurred on broadcaster fetch ..."));
+ },
+ });
+ };
+
+ // Update broadcasters list after building change
+ $("#event_building").change(function () {
+ $.ajax({
+ url: "/live/ajax_calls/getbroadcastersfrombuiding/",
+ type: "GET",
+ dataType: "JSON",
+ cache: false,
+ data: {
+ building: this.value,
+ },
+
+ success: (broadcasters) => {
+ broadcastField.html("");
+
+ if (broadcasters.length === 0) {
+ console.log("no Broadcaster");
+ broadcastField.prop("disabled", true);
+ broadcastField.append(
+ ""
+ );
+ } else {
+ broadcastField.prop("disabled", false);
+ $.each(broadcasters, (key, value) => {
+ broadcastField.append(
+ '"
+ );
+ });
+
+ // Update restriction after list reload
+ getBroadcasterRestriction();
+ }
+ },
+ error: () => {
+ alert(gettext("an error occurred during broadcasters load ..."));
+ },
+ });
+ });
+
+ // Update restriction after broadcaster change
+ broadcastField.change(function () {
+ getBroadcasterRestriction();
+ });
+
+ // Set restriction on load (if needed)
+ getBroadcasterRestriction();
+});
diff --git a/pod/live/static/js/viewcounter.js b/pod/live/static/js/viewcounter.js
index 99bc97f913..2dcafc6d15 100644
--- a/pod/live/static/js/viewcounter.js
+++ b/pod/live/static/js/viewcounter.js
@@ -17,7 +17,7 @@ $(document).ready(function () {
$.ajax({
type: "GET",
url:
- "/live/ajax_calls/heartbeat?key=" +
+ "/live/ajax_calls/heartbeat/?key=" +
secret +
"&liveid=" +
$("#livename").data("liveid"),
diff --git a/pod/live/templates/bbb/bbb_form.html b/pod/live/templates/bbb/bbb_form.html
new file mode 100644
index 0000000000..0069b4fc6d
--- /dev/null
+++ b/pod/live/templates/bbb/bbb_form.html
@@ -0,0 +1,22 @@
+{% load i18n %}
+
+
{{ broadcaster.description|safe }}
- {% if broadcaster.iframe_url != "" and broadcaster.iframe_url != None %} - - {% endif %} - {% if USE_BBB and USE_BBB_LIVE and display_chat %} -- {%if otherbroadcaster.status %}{%if otherbroadcaster.password%}{%else%}{%endif%} {{otherbroadcaster.name}} + {%if otherbroadcaster.status %} {{otherbroadcaster.name}} {%else%} {{otherbroadcaster.name}} ({% trans "no broadcast in progress" %}){%endif%}
{% endif %} diff --git a/pod/live/templates/live/building.html b/pod/live/templates/live/directs.html similarity index 77% rename from pod/live/templates/live/building.html rename to pod/live/templates/live/directs.html index ebe740ca0b..14f309bd38 100644 --- a/pod/live/templates/live/building.html +++ b/pod/live/templates/live/directs.html @@ -8,7 +8,7 @@ {% block breadcrumbs %} {{ block.super }} -+ src="{% url 'live:direct' slug=broadcaster.slug %}?is_iframe=true">
-- {%if broadcaster.status %}{%if broadcaster.password%}{%else%}{%endif%} {{broadcaster.name}} + {%if broadcaster.status %} {{broadcaster.name}} {%else%} {{broadcaster.name}} ({% trans "no broadcast in progress" %}){%endif%}
{% empty %}{% trans "Sorry, no lives found" %}.
diff --git a/pod/live/templates/live/event-all-info.html b/pod/live/templates/live/event-all-info.html new file mode 100644 index 0000000000..04979473b5 --- /dev/null +++ b/pod/live/templates/live/event-all-info.html @@ -0,0 +1,41 @@ +{% load i18n %} +{% load tagging_tags %} +{% trans 'This live is protected by password, please fill in and click send.' %}
-