diff options
Diffstat (limited to 'postgresqleu')
-rw-r--r-- | postgresqleu/membership/backendforms.py | 46 | ||||
-rw-r--r-- | postgresqleu/membership/backendviews.py | 83 | ||||
-rw-r--r-- | postgresqleu/membership/migrations/0006_webmeetings.py | 56 | ||||
-rw-r--r-- | postgresqleu/membership/models.py | 66 | ||||
-rw-r--r-- | postgresqleu/membership/views.py | 62 | ||||
-rw-r--r-- | postgresqleu/settings.py | 9 | ||||
-rw-r--r-- | postgresqleu/urls.py | 4 | ||||
-rw-r--r-- | postgresqleu/views.py | 1 |
8 files changed, 311 insertions, 16 deletions
diff --git a/postgresqleu/membership/backendforms.py b/postgresqleu/membership/backendforms.py index 6eba4e5e..63c67f1d 100644 --- a/postgresqleu/membership/backendforms.py +++ b/postgresqleu/membership/backendforms.py @@ -7,6 +7,7 @@ from collections import OrderedDict from postgresqleu.util.widgets import StaticTextWidget, EmailTextWidget from postgresqleu.util.backendforms import BackendForm from postgresqleu.membership.models import Member, MemberLog, Meeting, MembershipConfiguration +from postgresqleu.membership.models import MeetingType from postgresqleu.membership.backendlookups import MemberLookup @@ -67,16 +68,57 @@ class BackendMemberForm(BackendForm): class BackendMeetingForm(BackendForm): helplink = 'meetings' - list_fields = ['name', 'dateandtime', ] + list_fields = ['name', 'dateandtime', 'meetingtype', 'state'] + extrabuttons = [ + ('View meeting log', 'log/'), + ] class Meta: model = Meeting - fields = ['name', 'dateandtime', 'allmembers', 'members', 'botname', ] + fields = ['name', 'dateandtime', 'allmembers', 'members', 'meetingtype', 'meetingadmins', 'botname', ] + + fieldsets = [ + {'id': 'meeting_info', 'legend': 'Meeting information', 'fields': ['name', 'dateandtime', 'allmembers', 'members']}, + {'id': 'meeting_impl', 'legend': 'Meeting implementation', 'fields': ['meetingtype', 'meetingadmins', 'botname']}, + ] selectize_multiple_fields = { 'members': MemberLookup(), + 'meetingadmins': MemberLookup(), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Remove extra buttons unless we're in a web meeting and this web meeting has started + if self.instance: + if self.instance.meetingtype != MeetingType.WEB or self.instance.state == 0: + self.extrabuttons = [] + else: + self.extrabuttons = [] + + def clean(self): + d = super().clean() + if d.get('meetingtype', None) == MeetingType.WEB: + if d['botname']: + self.add_error('botname', 'Bot name should not be specified for web meetings') + if not d['meetingadmins']: + self.add_error('meetingadmins', 'Meeting administrator(s) must be specified for web meetings') + elif d.get('meetingtype', None) == MeetingType.IRC: + if not d['botname']: + self.add_error('botname', 'Bot name must be specified for IRC meetings') + if d['meetingadmins']: + self.add_error('meetingadmins', 'Meeting administrator(s) cannot be specified for IRC meetings') + return d + + def clean_meetingtype(self): + if self.cleaned_data.get('meetingtype', None) == MeetingType.WEB and not settings.MEETINGS_WS_BASE_URL: + raise ValidationError("Web meetings server is not configured in local_settings.py") + + if self.instance and self.instance.state > 0 and self.instance.meetingtype != self.cleaned_data['meetingtype']: + raise ValidationError("Cannot change the type of a meeting that has already started") + + return self.cleaned_data.get('meetingtype', None) + class BackendMemberSendEmailForm(django.forms.Form): helplink = 'membership' diff --git a/postgresqleu/membership/backendviews.py b/postgresqleu/membership/backendviews.py index ffdc2c16..b3ed04bd 100644 --- a/postgresqleu/membership/backendviews.py +++ b/postgresqleu/membership/backendviews.py @@ -1,8 +1,8 @@ from django.core.exceptions import PermissionDenied -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 from django.utils.html import escape -from django.db import transaction -from django.http import HttpResponseRedirect +from django.db import transaction, connection +from django.http import HttpResponseRedirect, HttpResponse, Http404 from django.contrib import messages from django.conf import settings @@ -10,10 +10,15 @@ from postgresqleu.util.backendviews import backend_list_editor, backend_process_ from postgresqleu.util.auth import authenticate_backend_group from postgresqleu.mailqueue.util import send_simple_mail from postgresqleu.membership.models import MembershipConfiguration, get_config, Member +from postgresqleu.membership.models import Meeting, MeetingMessageLog +from postgresqleu.membership.models import MeetingType from postgresqleu.membership.backendforms import BackendMemberForm, BackendMeetingForm from postgresqleu.membership.backendforms import BackendConfigForm from postgresqleu.membership.backendforms import BackendMemberSendEmailForm +import csv +import requests + def edit_config(request): if not request.user.is_superuser: @@ -107,3 +112,75 @@ def edit_meeting(request, rest): topadmin='Membership', return_url='/admin/', ) + + +def meeting_log(request, meetingid): + authenticate_backend_group(request, 'Membership administrators') + + meeting = get_object_or_404(Meeting, pk=meetingid) + + if meeting.meetingtype != MeetingType.WEB: + messages.warning(request, "Meeting log is only available for web meetings") + return HttpResponseRedirect("../") + + log = MeetingMessageLog.objects.select_related('sender').only('t', 'message', 'sender__fullname').filter(meeting=meeting) + + if request.method == 'POST': + with transaction.atomic(): + curs = connection.cursor() + curs.execute("""DELETE FROM membership_meetingmessagelog l WHERE meeting_id=%(meetingid)s AND ( + t < (SELECT min(t) FROM membership_meetingmessagelog l2 WHERE l2.meeting_id=%(meetingid)s AND l2.message='This meeting is now open.') +OR + t > (SELECT max(t) FROM membership_meetingmessagelog l3 WHERE l3.meeting_id=%(meetingid)s AND l3.message='This meeting is now finished.') +)""", {'meetingid': meetingid}) + messages.info(request, 'Removed {} entries from meeting {}.'.format(curs.rowcount, meetingid)) + return HttpResponseRedirect(".") + + if request.GET.get('format', None) == 'csv': + response = HttpResponse(content_type='text/csv; charset=utf8') + response['Content-Disposition'] = 'attachment;filename={} log.csv'.format(meeting.name) + c = csv.writer(response, delimiter=';') + c.writerow(['Time', 'Sender', 'Text']) + for l in log: + c.writerow([l.t, l.sender.fullname if l.sender else '', l.message]) + return response + else: + log = list(log.extra(select={ + 'inmeeting': "CASE WHEN t < (SELECT min(t) FROM membership_meetingmessagelog l2 WHERE l2.meeting_id=membership_meetingmessagelog.meeting_id AND message='This meeting is now open.') OR t > (SELECT max(t) FROM membership_meetingmessagelog l3 WHERE l3.meeting_id=membership_meetingmessagelog.meeting_id AND message='This meeting is now finished.') THEN false ELSE true END", + })) + return render(request, 'membership/meeting_log.html', { + 'meeting': meeting, + 'log': log, + 'numextra': sum(0 if l.inmeeting else 1 for l in log), + 'topadmin': 'Membership', + 'breadcrumbs': ( + ('/admin/membership/meetings/', 'Meetings'), + ('/admin/membership/meetings/{}/'.format(meeting.pk), meeting.name), + ), + }) + + +def meetingserverstatus(request): + if not request.user.is_superuser: + raise PermissionDenied("Access denied") + + if not settings.MEETINGS_STATUS_BASE_URL: + raise Http404() + + try: + if settings.MEETINGS_STATUS_BASE_URL.startswith('/'): + import requests_unixsocket + with requests_unixsocket.Session() as s: + r = s.get("http+unix://{}/__meetingstatus".format(settings.MEETINGS_STATUS_BASE_URL.replace('/', '%2F')), timeout=5) + else: + r = requests.get("{}/__meetingstatus".format(settings.MEETINGS_STATUS_BASE_URL), timeout=5) + r.raise_for_status() + error = None + except Exception as e: + error = str(e) + + return render(request, 'membership/meeting_server_status.html', { + 'status': None if error else r.json(), + 'error': error, + 'topadmin': 'Membership', + }) diff --git a/postgresqleu/membership/migrations/0006_webmeetings.py b/postgresqleu/membership/migrations/0006_webmeetings.py new file mode 100644 index 00000000..021e8028 --- /dev/null +++ b/postgresqleu/membership/migrations/0006_webmeetings.py @@ -0,0 +1,56 @@ +# Generated by Django 2.2.11 on 2021-01-22 16:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('membership', '0005_membership_sender_name'), + ] + + operations = [ + migrations.AddField( + model_name='meeting', + name='meetingadmins', + field=models.ManyToManyField(blank=True, related_name='admin_of_meetings', to='membership.Member', verbose_name='Meeting administrators'), + ), + migrations.AddField( + model_name='meeting', + name='meetingtype', + field=models.IntegerField(choices=[(0, 'IRC'), (1, 'Web')], default=0, verbose_name='Meeting type'), + ), + migrations.AddField( + model_name='meeting', + name='state', + field=models.IntegerField(choices=[(0, 'Pending'), (1, 'Started'), (2, 'Finished'), (3, 'Closed')], default=0), + ), + migrations.AddField( + model_name='membermeetingkey', + name='allowrejoin', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='meeting', + name='botname', + field=models.CharField(blank=True, max_length=50, verbose_name='Bot name'), + ), + migrations.CreateModel( + name='MeetingMessageLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('t', models.DateTimeField()), + ('message', models.TextField()), + ('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='membership.Meeting')), + ('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='membership.Member')), + ], + options={ + 'ordering': ('meeting', 't'), + }, + ), + migrations.AddIndex( + model_name='meetingmessagelog', + index=models.Index(fields=['meeting', 't'], name='membership__meeting_84cc48_idx'), + ), + ] diff --git a/postgresqleu/membership/models.py b/postgresqleu/membership/models.py index 924d5924..1812d29a 100644 --- a/postgresqleu/membership/models.py +++ b/postgresqleu/membership/models.py @@ -9,9 +9,28 @@ from postgresqleu.countries.models import Country from postgresqleu.invoices.models import Invoice, InvoicePaymentMethod from postgresqleu.membership.util import country_validator_choices +from collections import OrderedDict from datetime import timedelta +class MeetingType: + IRC = 0 + WEB = 1 + + CHOICES = OrderedDict(( + (IRC, "IRC"), + (WEB, "Web"), + )) + + +STATE_CHOICES = OrderedDict(( + (0, 'Pending'), + (1, 'Started'), + (2, 'Finished'), + (3, 'Closed'), +)) + + class MembershipConfiguration(models.Model): id = models.IntegerField(null=False, blank=False, primary_key=True) sender_email = LowercaseEmailField(null=False, blank=False, @@ -87,7 +106,10 @@ class Meeting(models.Model): dateandtime = models.DateTimeField(null=False, blank=False, verbose_name="Date and time") allmembers = models.BooleanField(null=False, blank=False, verbose_name="Open to all members") members = models.ManyToManyField(Member, blank=True, verbose_name="Open to specific members") - botname = models.CharField(max_length=50, null=False, blank=True) + meetingtype = models.IntegerField(null=False, blank=False, default=0, choices=MeetingType.CHOICES.items(), verbose_name="Meeting type") + meetingadmins = models.ManyToManyField(Member, blank=True, related_name='admin_of_meetings', verbose_name="Meeting administrators") + state = models.IntegerField(null=False, blank=False, default=0, choices=STATE_CHOICES.items()) + botname = models.CharField(max_length=50, null=False, blank=True, verbose_name='Bot name') def __str__(self): return "%s (%s)" % (self.name, self.dateandtime) @@ -96,17 +118,41 @@ class Meeting(models.Model): ordering = ['-dateandtime', ] @property - def joining_active(self): - if timezone.now() > self.dateandtime - timedelta(hours=4): + def is_open(self): + # Is this meeting open for joining (doesn't mean it has started!) + if timezone.now() > self.opentime: return True return False + @property + def opentime(self): + return self.dateandtime - timedelta(hours=2) + + @property + def is_started(self): + return self.state == 1 + + @property + def is_finished(self): + return self.state == 2 + def get_key_for(self, member): try: return MemberMeetingKey.objects.get(meeting=self, member=member) except MemberMeetingKey.DoesNotExist: return None + @property + def _display_meetingtype(self): + return MeetingType.CHOICES.get(self.meetingtype, 'Unknown') + + @property + def _display_state(self): + if self.meetingtype == MeetingType.WEB: + return STATE_CHOICES.get(self.state, 'Unknown') + else: + return '' + class MemberMeetingKey(models.Model): member = models.ForeignKey(Member, null=False, blank=False, on_delete=models.CASCADE) @@ -114,6 +160,20 @@ class MemberMeetingKey(models.Model): key = models.CharField(max_length=100, null=False, blank=False) proxyname = models.CharField(max_length=200, null=True, blank=False) proxyaccesskey = models.CharField(max_length=100, null=True, blank=False) + allowrejoin = models.BooleanField(null=False, blank=False, default=False) class Meta: unique_together = (('member', 'meeting'), ) + + +class MeetingMessageLog(models.Model): + t = models.DateTimeField(null=False, blank=False) + meeting = models.ForeignKey(Meeting, null=False, blank=False, on_delete=models.CASCADE) + sender = models.ForeignKey(Member, null=True, blank=True, on_delete=models.PROTECT) + message = models.TextField() + + class Meta: + indexes = ( + models.Index(fields=('meeting', 't')), + ) + ordering = ('meeting', 't', ) diff --git a/postgresqleu/membership/views.py b/postgresqleu/membership/views.py index cbe21cf8..05cfec5d 100644 --- a/postgresqleu/membership/views.py +++ b/postgresqleu/membership/views.py @@ -7,6 +7,7 @@ from django.db.models import Q from django.utils import timezone from .models import Member, MemberLog, Meeting, MemberMeetingKey, get_config +from .models import MeetingType from .forms import MemberForm, ProxyVoterForm from postgresqleu.util.random import generate_random_token @@ -127,7 +128,6 @@ def meetings(request): meetinginfo = [{ 'id': m.id, 'name': m.name, - 'joining_active': m.joining_active, 'dateandtime': m.dateandtime, 'key': m.get_key_for(member), } for m in meetings] @@ -155,9 +155,6 @@ def _meeting(request, member, meeting, isproxy): if member.paiduntil < timezone.localdate(meeting.dateandtime): return HttpResponse("Your membership expires before the meeting") - if not meeting.joining_active: - return HttpResponse("This meeting is not open for joining yet") - # All is well with this member. Generate a key if necessary (key, created) = MemberMeetingKey.objects.get_or_create(member=member, meeting=meeting) if created: @@ -168,11 +165,21 @@ def _meeting(request, member, meeting, isproxy): if key.proxyname and not isproxy: return HttpResponse("You have assigned a proxy attendee for this meeting ({0}). This means you cannot attend the meeting yourself.".format(key.proxyname)) - return render(request, 'membership/meeting.html', { - 'member': member, - 'meeting': meeting, - 'key': key, + if meeting.meetingtype == MeetingType.WEB: + return render(request, 'membership/webmeeting.html', { + 'member': member, + 'meeting': meeting, + 'key': key, + 'is_admin': request.user.is_superuser, }) + elif meeting.meetingtype == MeetingType.IRC: + return render(request, 'membership/meeting.html', { + 'member': member, + 'meeting': meeting, + 'key': key, + }) + else: + raise Exception("Unknown meeting type") @login_required @@ -188,6 +195,45 @@ def meeting_by_key(request, meetingid, token): return _meeting(request, key.member, key.meeting, True) +def _webmeeting(request, key): + if not settings.MEETINGS_WS_BASE_URL: + raise Http404 + + is_admin = key.meeting.meetingadmins.filter(pk=key.member.pk).exists() + + return render(request, 'membership/webmeeting_implementation.html', { + 'meeting': key.meeting, + 'member': key.member, + 'key': key, + 'is_admin': is_admin, + 'wsbaseurl': settings.MEETINGS_WS_BASE_URL.rstrip("/"), + }) + + +@login_required +def webmeeting(request, meetingid): + meeting = get_object_or_404(Meeting, pk=meetingid) + member = get_object_or_404(Member, user=request.user) + + # At this point the user must have a key for this meeting. If not, we redirect them back to + # get one. And if they have a key, the permissions checks have been done there. + try: + key = MemberMeetingKey.objects.get(member=member, meeting=meeting) + except MemberMeetingKey.DoesNotExist: + return HttpResponseRedirect("../") + + # If a proxy is assigned, the original member is not allowed to join + if key.proxyaccesskey: + return HttpResponseRedirect("../") + + return _webmeeting(request, key) + + +def webmeeting_by_key(request, meetingid, token): + key = get_object_or_404(MemberMeetingKey, proxyaccesskey=token) + return _webmeeting(request, key) + + @login_required @transaction.atomic def meeting_proxy(request, meetingid): diff --git a/postgresqleu/settings.py b/postgresqleu/settings.py index 4f876352..c57f4fcc 100644 --- a/postgresqleu/settings.py +++ b/postgresqleu/settings.py @@ -201,6 +201,15 @@ TREASURER_EMAIL = DEFAULT_EMAIL # performance overhead) is used. RELOAD_WATCH_DIRECTORIES = [] +# If using the web based meetings, base URL for the web sockets server that +# handles the messages. +# Typically something like wss://some.domain.org/ws/meeting +MEETINGS_WS_BASE_URL = None +# If using the web based meetings, base URL to retrieve the status for the +# meeting server. If it starts with /, a unix socket in that location will +# be used instead of TCP. +MEETINGS_STATUS_BASE_URL = None + # If there is a local_settings.py, let it override our settings try: from .local_settings import * diff --git a/postgresqleu/urls.py b/postgresqleu/urls.py index a57f9d2f..d5d959da 100644 --- a/postgresqleu/urls.py +++ b/postgresqleu/urls.py @@ -390,11 +390,15 @@ if settings.ENABLE_MEMBERSHIP: url(r'^membership/meetings/(\d+)/$', postgresqleu.membership.views.meeting), url(r'^membership/meetings/(\d+)/([a-z0-9]{64})/$', postgresqleu.membership.views.meeting_by_key), url(r'^membership/meetings/(\d+)/proxy/$', postgresqleu.membership.views.meeting_proxy), + url(r'^membership/meetings/(\d+)/join/$', postgresqleu.membership.views.webmeeting), + url(r'^membership/meetings/(\d+)/([a-z0-9]{64})/join/$', postgresqleu.membership.views.webmeeting_by_key), url(r'^membership/meetingcode/$', postgresqleu.membership.views.meetingcode), url(r'^membership/members/$', postgresqleu.membership.views.userlist), url(r'^admin/membership/config/$', postgresqleu.membership.backendviews.edit_config), url(r'^admin/membership/members/sendmail/$', postgresqleu.membership.backendviews.sendmail), url(r'^admin/membership/members/(.*/)?$', postgresqleu.membership.backendviews.edit_member), + url(r'^admin/membership/meetings/(\d+)/log/$', postgresqleu.membership.backendviews.meeting_log), + url(r'^admin/membership/meetings/serverstatus/$', postgresqleu.membership.backendviews.meetingserverstatus), url(r'^admin/membership/meetings/(.*/)?$', postgresqleu.membership.backendviews.edit_meeting), url(r'^admin/membership/lookups/member/$', postgresqleu.membership.backendlookups.MemberLookup.lookup), ]) diff --git a/postgresqleu/views.py b/postgresqleu/views.py index 28a1263a..17daec52 100644 --- a/postgresqleu/views.py +++ b/postgresqleu/views.py @@ -175,6 +175,7 @@ def admin_dashboard(request): 'bank_file_uploads': bank_file_uploads, 'schedalert': conditional_exec_to_scalar(request.user.is_superuser, "SELECT NOT EXISTS (SELECT 1 FROM pg_stat_activity WHERE application_name='pgeu scheduled job runner' AND datname=current_database())"), 'mailqueuealert': conditional_exec_to_scalar(request.user.is_superuser, "SELECT EXISTS (SELECT 1 FROM mailqueue_queuedmail LIMIT 1)"), + 'meetingserver': settings.ENABLE_MEMBERSHIP and settings.MEETINGS_STATUS_BASE_URL, }) |