diff --git a/jitsimod/__init__.py b/jitsimod/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/jitsimod/admin.py b/jitsimod/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..a36be30d10a4ac8272079bd4d38101bff96a0217 --- /dev/null +++ b/jitsimod/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin +from .models import Room, Pattern + +class RoomAdmin(admin.ModelAdmin): + def moderator_names(self): + return ", ".join(["{} {}".format(l.first_name, l.last_name) if (l.first_name or l.last_name) else l.username for l in self.moderators.all()]) + + list_display = ('name', moderator_names) + autocomplete_fields = ["moderators"] + +admin.site.register(Room, RoomAdmin) + +class PatternAdmin(admin.ModelAdmin): + def whitelisted_names(self): + return ", ".join(["{} {}".format(l.first_name, l.last_name) if (l.first_name or l.last_name) else l.username for l in self.whitelisted.all()]) + + list_display = ('pattern', whitelisted_names) + autocomplete_fields = ["whitelisted"] + +admin.site.register(Pattern, PatternAdmin) + diff --git a/jitsimod/apps.py b/jitsimod/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..9ccabd45cf6555e93e97cdba4b5cbf7a16ffe503 --- /dev/null +++ b/jitsimod/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class JitsimodConfig(AppConfig): + name = 'jitsimod' diff --git a/jitsimod/forms.py b/jitsimod/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..d76d0fde2f77c37a69ac3aee6a2a93f12e9dd5e9 --- /dev/null +++ b/jitsimod/forms.py @@ -0,0 +1,65 @@ +from django import forms +from django.contrib.auth import get_user_model +from .models import Room + +class MultiUserField(forms.Field): + users = [] + + def get_user(self, username): + try: + return get_user_model().objects.get(username=username) + except: + return None + + def to_python(self, value): + if not value: + return [] + return list(value.replace(",", " ").split()) + + def get_users(self): + return self.users + + def validate(self, usernames): + if len(usernames) > 10: + raise ValidationError("Only 10 moderators can be added to a room by yourself. If you need more contact an administrator at admin@fslab.de.") + + failed = [] + users = [] + for username in usernames: + user = self.get_user(username) + if user is None: + failed.append(username) + else: + users.append(user) + + if failed: + raise ValidationError('User%s not found: %s\nPlease note: FB02 user accounts have to sign in at least once to be created on the platform!' % ('s' if len(failed) > 1 else '', ", ".join(failed))) + else: + self.users = users + +class RoomForm(forms.ModelForm): + other_moderators = MultiUserField(label='Other moderators (optional; usernames separated by whitespace and/or commata)', required=False, widget=forms.TextInput(attrs={'class': 'w3-input w3-border w3-light-grey'})) + + class Meta: + model = Room + fields = ['name', 'reason', 'other_moderators'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'w3-input w3-border w3-light-grey', 'autocomplete': 'off'}), + 'reason': forms.Textarea(attrs={'class': 'w3-input w3-border w3-light-grey', 'autocomplete': 'off'}), + } + labels = { + 'reason': 'Approval reason (leave empty if you have been given a whitelisting pattern)', + } + + def __init__(self, *args, **kwargs): + initial = {} + if 'instance' in kwargs: + initial["other_moderators"] = ", ".join(list(l.username for l in kwargs['instance'].moderators.all() if l.username != kwargs['request'].user.username)) + + request = kwargs['request'] + del kwargs['request'] + + forms.ModelForm.__init__(self, *args, **kwargs, initial = initial) + if 'instance' in kwargs: + del self.fields['name'] + del self.fields['reason'] diff --git a/jitsimod/migrations/0001_initial.py b/jitsimod/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..59857710d93e5d1cd68b1392fcdafd68c1cff857 --- /dev/null +++ b/jitsimod/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.5 on 2020-04-29 17:50 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Room', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=40, unique=True)), + ('approved', models.BooleanField(default=False)), + ('moderators', models.ManyToManyField(blank=True, related_name='jitsi_moderating', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Pattern', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pattern', models.CharField(max_length=40, unique=True)), + ('whitelisted', models.ManyToManyField(blank=True, related_name='jitsi_whitelisted', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/jitsimod/migrations/0002_room_reason.py b/jitsimod/migrations/0002_room_reason.py new file mode 100644 index 0000000000000000000000000000000000000000..d6ae8c052a7db2e1aeba3d22e535ac2090330bd2 --- /dev/null +++ b/jitsimod/migrations/0002_room_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-04-29 18:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jitsimod', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='room', + name='reason', + field=models.TextField(blank=True, null=True, verbose_name='Approval Reason'), + ), + ] diff --git a/jitsimod/migrations/__init__.py b/jitsimod/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/jitsimod/modapi.py b/jitsimod/modapi.py new file mode 100644 index 0000000000000000000000000000000000000000..85dab7b10c3a7263655aeb049c862c860aa23db4 --- /dev/null +++ b/jitsimod/modapi.py @@ -0,0 +1,24 @@ +from django.conf import settings +import json +import requests +from requests.auth import HTTPBasicAuth + +auth = HTTPBasicAuth('modapi', settings.JITSI_MODAPI_SECRET) + +def modapi_get(path): + return json.loads(requests.get("%s/%s" % (settings.JITSI_MODAPI_URL, path), auth=auth, timeout=3).text) + +def modapi_post(path, data): + return requests.post("%s/%s" % (settings.JITSI_MODAPI_URL, path), auth=auth, data=data, timeout=3).text + +def modapi_get_room(room): + return modapi_get("rooms/%s" % room) + +def modapi_reset_password(room): + return modapi_post("rooms/%s" % room, {'resetpassword': 1}) + +def modapi_grant_moderator(room, nick): + return modapi_post("rooms/%s" % room, {'grant': nick}) + +def modapi_revoke_moderator(room, nick): + return modapi_post("rooms/%s" % room, {'revoke': nick}) diff --git a/jitsimod/models.py b/jitsimod/models.py new file mode 100644 index 0000000000000000000000000000000000000000..81eb12a808896f02e782fded64a8b276301fe26d --- /dev/null +++ b/jitsimod/models.py @@ -0,0 +1,12 @@ +from django.db import models +from django.contrib.auth import get_user_model + +class Room(models.Model): + name = models.CharField(blank=False, null=False, max_length=40, unique=True) + moderators = models.ManyToManyField(get_user_model(), related_name='jitsi_moderating', blank=True) + reason = models.TextField('Approval Reason', null=True, blank=True) + approved = models.BooleanField(default=False) + +class Pattern(models.Model): + pattern = models.CharField(blank=False, null=False, max_length=40, unique=True) + whitelisted = models.ManyToManyField(get_user_model(), related_name='jitsi_whitelisted', blank=True) diff --git a/jitsimod/templates/jitsi/forms/room.html b/jitsimod/templates/jitsi/forms/room.html new file mode 100644 index 0000000000000000000000000000000000000000..101d2af7866c6b9c2b683303509c90ae56db06c3 --- /dev/null +++ b/jitsimod/templates/jitsi/forms/room.html @@ -0,0 +1,29 @@ +<div class="w3-card-4"> + <div class="w3-container w3-hochschulblau"> + <h2>{{ formtitle|default:'form' }}</h2><a name="roomform"></a> + </div> + {% if form.non_field_errors %} + <div class="w3-panel w3-pale-red w3-leftbar w3-border-red"> + {% for error in form.non_field_errors %}{{ error|linebreaksbr }}<br />{% endfor %} + </div> + {% endif %} + <form action="{{ action|default:'?' }}" method="post" class="w3-container"> + {% csrf_token %} + {% for field in form %} + <p> + <label for="{{ field.id_for_label }}"><b>{{ field.label }}</b></label> + {% if field.errors %} + <div class="w3-panel w3-pale-red w3-leftbar w3-border-red"> + {% for error in field.errors %}{{ error|linebreaksbr }}<br />{% endfor %} + </div> + {% endif %} + {{ field }} + </p> + {% endfor %} + <p> + <input class="w3-input w3-green" type="submit" name="saveroom" value="Submit" /> + </p> + </form> + +</div> + diff --git a/jitsimod/templates/jitsi/newroom.html b/jitsimod/templates/jitsi/newroom.html new file mode 100644 index 0000000000000000000000000000000000000000..d00aead4bc0d17a322665bc25206bef0841a253c --- /dev/null +++ b/jitsimod/templates/jitsi/newroom.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block content %} + +{% include "jitsi/forms/room.html" with formtitle="Create new room" %} + +{% endblock content %} + diff --git a/jitsimod/templates/jitsi/room.html b/jitsimod/templates/jitsi/room.html new file mode 100644 index 0000000000000000000000000000000000000000..9594d514700763b7d74a16ec2adaada8aa1c0dce --- /dev/null +++ b/jitsimod/templates/jitsi/room.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} + +{% block content %} + +<h2>{{ room.name }}</h2> + +{% if form.errors %} +<div class="w3-panel w3-pale-red w3-leftbar w3-border-red"> + <p>There was an issue storing the room settings, see details on form below</p> +</div> +{% elif updated %} +<div class="w3-panel w3-pale-green w3-leftbar w3-border-green"> + <p>Room updated successfully</p> +</div> +{% endif %} + +{% if modapi_details.password %} +<div class="w3-card-4"> + <div class="w3-container w3-hochschulblau"> + <h2>Room Password</h2> + </div> + <form id="passwordreset-modform" action="?" method="post" class="w3-container"> + <p>This room is password protected.</p> + {% csrf_token %} + <p><input class="w3-input w3-green" type="submit" name="resetpassword" value="Remove password" /></p> + </form> +</div> +<br/> +{% endif %} + +<div class="w3-card-4"> + <div class="w3-container w3-hochschulblau"> + <h2>Room Occupants</h2> + </div> + <ul class="w3-ul w3-border"> + {% if modapi_details.occupants %} + {% for occupant in modapi_details.occupants %} + <li class="w3-bar"> + {% if occupant.role == "moderator" %} + <img src="/static/icons/star.svg" class="w3-bar-item" style="width:55px"> + {% else %} + <img src="/static/icons/person.svg" class="w3-bar-item" style="width:55px"> + {% endif %} + <div class="w3-bar-item"><span>{{ occupant.nick }}</span></div> + <div class="w3-bar-item w3-right w3-right-align"> + <form id="roomoccupant-modform-{{ forloop.counter }}" action="?" method="post">{% csrf_token %}<input type="hidden" name="{% if occupant.role == "moderator" %}revoke{% else %}grant{% endif %}" value="{{ occupant.nick }}" /></form> + <span><a href="#" onclick='document.getElementById("roomoccupant-modform-{{ forloop.counter }}").submit();' style='color:red;'>{% if occupant.role == "moderator" %}revoke{% else %}grant{% endif %} moderator permissions</a></span> + </div> + </li> + {% endfor %} + {% else %} + <li>Room seems to be empty</li> + {% endif %} + </ul> +</div> +<br/> + +{% if recordings %} +<div class="w3-card-4"> + <div class="w3-container w3-hochschulblau"> + <h2>Recordings</h2> + </div> + <ul class="w3-ul w3-border"> + {% for video in recordings %} + <li class="w3-bar"> + {% if video.masterfile %}<a href="/video/{{ video.key }}">{% endif %} + {% if video.thumbnail %} + <img src="{{ MEDIA_URL }}{{ video.thumbnail }}?key={{ video.key }}×tamp={{ video.updated_at|date:'U' }}" class="w3-bar-item" style="width:128px" /> + {% else %} + <img src="/static/icons/video.svg" class="w3-bar-item" style="width:128px;padding-left:30px;padding-right:30px;" /> + {% endif %} + {% if video.masterfile %}</a>{% endif %} + <div class="w3-bar-item"> + <span>{% if video.masterfile %}<a href="/video/{{ video.key }}">{% else %}<span class='w3-text-orange'>{% endif %}{{ video.name }}{% if video.masterfile %}</a>{% else %}</span>{% endif %}</span><br/> + <span class="w3-text-blue">State: {{ video.state }}{% if video.worker %} ({% if video.failed %}Processing Failed! {% endif %}Worker: {{ video.worker.name }}){% endif %}</span><br/> + </div> + <div class="w3-bar-item w3-right w3-right-align"> + <span>Uploaded: {{ video.created_at|date:"d.m.Y" }}</span><br/> + <form id="video-deleteform-{{ video.id }}" action="?" method="post">{% csrf_token %}<input type="hidden" name="deleterecording" value="{{ video.id }}" /></form> + <span><a href="#" onclick='if(confirm("Do you really want to delete this file?")) document.getElementById("video-deleteform-{{ video.id }}").submit();' class='w3-text-red'>Delete</a> | <a href="{{ MEDIA_URL }}{{ video.original }}?key={{ video.key }}×tamp={{ video.created_at|date:'U' }}" download>Download</a></span> + </div> + </li> + {% endfor %} + </ul> +</div> +<br /> +{% endif %} + +{% include "jitsi/forms/room.html" with formtitle="Edit room" %} +</div> + +{% endblock content %} + + diff --git a/jitsimod/templates/jitsi/rooms.html b/jitsimod/templates/jitsi/rooms.html new file mode 100755 index 0000000000000000000000000000000000000000..b7e0ee70500d4007505c5f24790d64bfc7ad470a --- /dev/null +++ b/jitsimod/templates/jitsi/rooms.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block content %} + +<h2>Jitsi Rooms</h2> + +{% if request.user.jitsi_moderating.exists %} +<div class="w3-card-4"> + <div class="w3-container w3-hochschulblau"> + <h2>Your Rooms</h2> + </div> + <ul class="w3-ul w3-border"> + {% for room in rooms %} + {% if room.approved %} + <li><a href="/jitsi/{{ room.id }}">{{ room.name }}</a></li> + {% else %} + <li>{{ room.name }} (pending approval)</li> + {% endif %} + {% endfor %} + </ul> +</div> +<br/> +{% else %} +<!-- no owned rooms --> +{% endif %} + +{% if patterns %} +<div class="w3-card-4"> + <div class="w3-container w3-hochschulblau"> + <h2>Whitelisted Patterns (Automatic Approval)</h2> + </div> + <ul class="w3-ul w3-border"> + {% for pattern in patterns %} + <li>{{ pattern.pattern }}</li> + {% endfor %} + </ul> +</div> +<br/> +{% else %} +<!-- no whitelisted patterns --> +{% endif %} + +<a href="/jitsi/add" class="w3-button w3-hochschulblau">Request Room</a> +</p> + +{% endblock content %} diff --git a/jitsimod/tests.py b/jitsimod/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/jitsimod/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/jitsimod/urls.py b/jitsimod/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..323a63ddd23f90d32dd15a65e0f6c6847259e631 --- /dev/null +++ b/jitsimod/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, re_path + +from . import views + +urlpatterns = [ + path('jitsi', views.rooms, name='rooms'), + path('jitsi/add', views.newroom, name='newroom'), + path('jitsi/<int:room_id>', views.room, name='room'), +] diff --git a/jitsimod/views.py b/jitsimod/views.py new file mode 100644 index 0000000000000000000000000000000000000000..1331a9a615acae2ab6c7df3e29ba0fa11aad11e5 --- /dev/null +++ b/jitsimod/views.py @@ -0,0 +1,112 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.contrib.auth import get_user_model +from django.core.mail import EmailMessage +from .forms import RoomForm +from .models import Room +import re +from .modapi import modapi_get_room, modapi_reset_password, modapi_grant_moderator, modapi_revoke_moderator +from videos.models import Course + +@login_required +def rooms(request): + return render(request, "jitsi/rooms.html", context={'rooms': request.user.jitsi_moderating.all().order_by("name"), 'patterns': request.user.jitsi_whitelisted.all().order_by("pattern")}) + +@login_required +def newroom(request): + if request.method == "POST": + form = RoomForm(request.POST, request=request) + if form.is_valid(): + room = Room() + room.name = form.cleaned_data["name"] + room.reason = form.cleaned_data["reason"] + room.save() + + room.moderators.add(request.user) + for user in form.fields["other_moderators"].get_users(): + if user != request.user: + room.moderators.add(user) + + autoapproved = False + for pattern in request.user.jitsi_whitelisted.all(): + if re.match("^%s$" % pattern.pattern, room.name): + autoapproved = True + room.approved = True + room.save() + + for admin in get_user_model().objects.filter(is_superuser=True, is_active=True): + if admin.username != "lschau2s": + continue + subject = 'created (pattern-approved)' if autoapproved else 'requested' + msg = "Hello %s,\n\na new Jitsi room has been %s:\nhttps://lectures.fslab.de/admin/jitsimod/pattern/%d/change/\nName: %s\nUser: %s\nReason:\n%s" % (admin.first_name, subject, room.id, room.name, request.user.username, room.reason) + email = EmailMessage('lectures.fslab.de – Admin – Jitsi Room %s' % subject, msg, to=[admin.email]) + try: + email.send() + except: + traceback.print_exc() + pass + + if autoapproved: + return redirect("/jitsi/{}".format(room.id)) + else: + return redirect("/jitsi?requested=1") + else: + form = RoomForm(request=request) + + return render(request, 'jitsi/newroom.html', {'form': form}) + +@login_required +def room(request, room_id): + room = get_object_or_404(Room, id=room_id) + if not room.approved or (not room.moderators.filter(username=request.user.username).exists() and not request.user.is_staff): + return render(request, "forbidden.html") + + try: + recordings = list(Course.objects.get(owner__username="jitsi-recordings").video_set.filter(description=room.name)) + except: + recordings = [] + + updated = request.GET.get("saved") is not None + form = None + if request.method == "POST": + if request.POST.get("grant"): + modapi_grant_moderator(room.name, request.POST.get("grant")) + return redirect("/jitsi/{}?saved=1".format(room.id)) + elif request.POST.get("revoke"): + modapi_revoke_moderator(room.name, request.POST.get("revoke")) + return redirect("/jitsi/{}?saved=1".format(room.id)) + elif request.POST.get("resetpassword") is not None: + modapi_reset_password(room.name) + return redirect("/jitsi/{}?saved=1".format(room.id)) + elif request.POST.get("deleterecording") is not None: + todelete = [] + for recording in recordings: + if str(recording.id) == str(request.POST.get("deleterecording")): + todelete.append(recording) + + for recording in todelete: + recordings.remove(recording) + recording.delete() + + elif request.POST.get("saveroom") is not None: + form = RoomForm(request.POST, instance=room, request=request) + if form.is_valid(): + room = form.save(commit=False) + moderators = form.fields["other_moderators"].get_users() + for user in room.moderators.all(): + if user not in moderators and user.username != request.user.username: + room.moderators.remove(user) + for user in moderators: + if user not in room.moderators.all(): + room.moderators.add(user) + room.save() + + return redirect("/jitsi/{}?saved=1".format(room.id)) + + if form is None: + form = RoomForm(instance=room, request=request) + + modapi_details = modapi_get_room(room.name) + + return render(request, "jitsi/room.html", context={'room': room, 'form': form, 'updated': updated, 'modapi_details': modapi_details, 'recordings': recordings}) + diff --git a/templates/base.html b/templates/base.html index 8ec8fab6a99457edfa15922ac6282cf592efaae3..dfb3da3403e0c29f83bc566fdbaca41d6819138f 100755 --- a/templates/base.html +++ b/templates/base.html @@ -29,7 +29,8 @@ {% if request.user.is_authenticated %} <a href="/" class="w3-bar-item w3-button w3-hover-white">{% if request.user.first_name or request.user.last_name %}{{ request.user.first_name }} {{ request.user.last_name }}{% else %}{{ request.user.username }}{% endif %}</a> <a href="{% url 'logout' %}" class="w3-bar-item w3-button w3-hover-white w3-right">Logout</a> - {% if request.user.is_staff %}<a href="/admin" class="w3-bar-item w3-button w3-hover-white w3-right">Admin</a>{% endif %} + {% if request.user.is_staff %}<a href="/admin" class="w3-bar-item w3-button w3-red w3-hover-white w3-right">Admin</a>{% endif %} + {% if request.user.jitsi_moderating.exists or request.user.jitsi_whitelisted.exists %}<a href="/jitsi" class="w3-bar-item w3-button w3-hover-white w3-right">Jitsi</a>{% endif %} {% endif %} </div> </div> diff --git a/videoportal/urls.py b/videoportal/urls.py index d803f089ec23b4c7f9ee1a180b3e0dcc096e834b..a92c6f9e8966d1158c7979a96f15e9142b59a1c3 100755 --- a/videoportal/urls.py +++ b/videoportal/urls.py @@ -2,6 +2,7 @@ from django.contrib import admin from django.urls import path from videos import urls as videos from fhuser import urls as fhuser +from jitsimod import urls as jitsimod from django.conf import settings from django.conf.urls.static import static from django.contrib.auth import views as auth_views @@ -17,6 +18,7 @@ urlpatterns += [ urlpatterns += videos.urlpatterns urlpatterns += fhuser.urlpatterns +urlpatterns += jitsimod.urlpatterns if settings.DEBUG: urlpatterns += static("/foo/", document_root=settings.MEDIA_ROOT)