summaryrefslogtreecommitdiff
path: root/postgresqleu
diff options
context:
space:
mode:
Diffstat (limited to 'postgresqleu')
-rw-r--r--postgresqleu/membership/backendforms.py46
-rw-r--r--postgresqleu/membership/backendviews.py83
-rw-r--r--postgresqleu/membership/migrations/0006_webmeetings.py56
-rw-r--r--postgresqleu/membership/models.py66
-rw-r--r--postgresqleu/membership/views.py62
-rw-r--r--postgresqleu/settings.py9
-rw-r--r--postgresqleu/urls.py4
-rw-r--r--postgresqleu/views.py1
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,
})