Add support for token based access to admin information
authorMagnus Hagander <magnus@hagander.net>
Wed, 23 May 2018 18:54:52 +0000 (20:54 +0200)
committerMagnus Hagander <magnus@hagander.net>
Wed, 23 May 2018 19:06:59 +0000 (21:06 +0200)
This adds a new object for each conference that is an AccessToken. This
token can be given permissions to access specific types of data in a
structured format, which can then be loaded using an URL with that token
in it.

Initially this exports a number of datasets that are useful to feed into
a budget spreadsheet or similar, but the model and code is structured to
make it easy to add completely different exports as well in the future.

postgresqleu/confreg/admin.py
postgresqleu/confreg/backendforms.py
postgresqleu/confreg/backendviews.py
postgresqleu/confreg/migrations/0023_accesstokens.py [new file with mode: 0644]
postgresqleu/confreg/models.py
postgresqleu/confsponsor/util.py [new file with mode: 0644]
postgresqleu/urls.py
template/confreg/admin_dashboard_single.html

index 39a4e09eb30870f388221d34bb7c362051c49936..ce725d3a0ca5b60415ae301e98f05c35ead3b552 100644 (file)
@@ -18,6 +18,7 @@ from models import ConferenceFeedbackQuestion, Speaker_Photo
 from models import PrepaidVoucher, PrepaidBatch, BulkPayment, DiscountCode
 from models import PendingAdditionalOrder
 from models import VolunteerSlot
+from models import AccessToken
 
 from selectable.forms.widgets import AutoCompleteSelectWidget, AutoCompleteSelectMultipleWidget
 from postgresqleu.accountinfo.lookups import UserLookup
@@ -618,3 +619,4 @@ admin.site.register(BulkPayment, BulkPaymentAdmin)
 admin.site.register(AttendeeMail, AttendeeMailAdmin)
 admin.site.register(PendingAdditionalOrder, PendingAdditionalOrderAdmin)
 admin.site.register(VolunteerSlot, VolunteerSlotAdmin)
+admin.site.register(AccessToken)
index 87fc78f9ea4aada41efa9b068b164740dd1b2cf4..b893c4d61d00f04ba24d4c79b80ace17995e9ce7 100644 (file)
@@ -4,6 +4,7 @@ from django.db.models import Q
 import django.forms
 import django.forms.widgets
 from django.forms.widgets import TextInput
+from django.utils.safestring import mark_safe
 
 import datetime
 from psycopg2.extras import DateTimeTZRange
@@ -12,6 +13,7 @@ from selectable.forms.widgets import AutoCompleteSelectWidget, AutoCompleteSelec
 
 from postgresqleu.util.admin import SelectableWidgetAdminFormMixin
 from postgresqleu.util.forms import ConcurrentProtectedModelForm
+from postgresqleu.util.random import generate_random_token
 
 from postgresqleu.accountinfo.lookups import UserLookup
 from postgresqleu.confreg.lookups import RegistrationLookup
@@ -21,7 +23,7 @@ from postgresqleu.confreg.models import RegistrationClass, RegistrationType, Reg
 from postgresqleu.confreg.models import ConferenceAdditionalOption, ConferenceFeedbackQuestion
 from postgresqleu.confreg.models import ConferenceSession, Track, Room
 from postgresqleu.confreg.models import ConferenceSessionScheduleSlot, VolunteerSlot
-from postgresqleu.confreg.models import DiscountCode
+from postgresqleu.confreg.models import DiscountCode, AccessToken, AccessTokenPermissions
 
 from postgresqleu.confreg.models import valid_status_transitions, get_status_string
 
@@ -543,6 +545,30 @@ class BackendDiscountCodeForm(BackendForm):
                self.update_protected_fields()
 
 
+class BackendAccessTokenForm(BackendForm):
+       list_fields = ['token', 'description', 'permissions', ]
+       readonly_fields = ['token', ]
+
+       class Meta:
+               model = AccessToken
+               fields = ['token', 'description', 'permissions', ]
+
+       def _transformed_accesstoken_permissions(self):
+               for k,v in AccessTokenPermissions:
+                       baseurl = '/events/admin/{0}/tokendata/{1}/{2}'.format(self.conference.urlname, self.instance.token, k)
+                       yield k, mark_safe('{0} (<a href="{1}.csv">csv</a>, <a href="{1}.tsv">tsv</a>)'.format(v, baseurl))
+
+       def fix_fields(self):
+               self.fields['permissions'].widget = django.forms.CheckboxSelectMultiple(
+                       choices=self._transformed_accesstoken_permissions(),
+               )
+
+       @classmethod
+       def get_initial(self):
+               return {
+                       'token': generate_random_token()
+               }
+
 #
 # Form to pick a conference to copy from
 #
index a4219a8b2832a17ad94f96e31f622dae4df792f8..a0c1a511d2a7b84efbdf69a2dae01ec0ad471655 100644 (file)
@@ -2,7 +2,7 @@ from django.shortcuts import render, get_object_or_404
 from django.db import transaction
 from django import forms
 from django.core import urlresolvers
-from django.http import HttpResponseRedirect, Http404
+from django.http import HttpResponse, HttpResponseRedirect, Http404
 from django.contrib.admin.utils import NestedObjects
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
@@ -10,14 +10,17 @@ from django.conf import settings
 
 import urllib
 import datetime
+import csv
 
 from postgresqleu.util.middleware import RedirectException
-from postgresqleu.util.db import exec_to_dict, exec_no_result
+from postgresqleu.util.db import exec_to_list, exec_to_dict, exec_no_result
 
 from models import Conference, ConferenceRegistration
 from models import RegistrationType, RegistrationClass
+from models import AccessToken
 
 from postgresqleu.invoices.models import Invoice
+from postgresqleu.confsponsor.util import get_sponsor_dashboard_data
 
 from backendforms import BackendCopySelectConferenceForm
 from backendforms import BackendConferenceForm, BackendRegistrationForm
@@ -26,6 +29,7 @@ from backendforms import BackendRegistrationDayForm, BackendAdditionalOptionForm
 from backendforms import BackendTrackForm, BackendRoomForm, BackendConferenceSessionForm
 from backendforms import BackendConferenceSessionSlotForm, BackendVolunteerSlotForm
 from backendforms import BackendFeedbackQuestionForm, BackendDiscountCodeForm
+from backendforms import BackendAccessTokenForm
 
 def get_authenticated_conference(request, urlname):
        if not request.user.is_authenticated:
@@ -375,6 +379,12 @@ def edit_discountcodes(request, urlname, rest):
                                                           BackendDiscountCodeForm,
                                                           rest)
 
+def edit_accesstokens(request, urlname, rest):
+       return backend_list_editor(request,
+                                                          urlname,
+                                                          BackendAccessTokenForm,
+                                                          rest)
+
 
 ###
 # Non-simple-editor views
@@ -420,3 +430,50 @@ FROM confreg_conferenceregistration WHERE conference_id=%(confid)s""", {
        'confid': conference.id,
                })[0],
        })
+
+
+
+def _reencode_row(r):
+       def _reencode_value(v):
+               if isinstance(v, unicode):
+                       return v.encode('utf-8')
+               return v
+       return [_reencode_value(x) for x in r]
+
+def tokendata(request, urlname, token, datatype, dataformat):
+       conference = get_object_or_404(Conference, urlname=urlname)
+       if not AccessToken.objects.filter(conference=conference, token=token, permissions__contains=[datatype,]).exists():
+               raise Http404()
+
+       if dataformat.lower() == 'csv':
+               delimiter = ","
+       elif dataformat.lower() == 'tsv':
+               delimiter = "\t"
+       else:
+               raise Http404()
+
+       response = HttpResponse(content_type='text/plain; charset=utf-8')
+       writer = csv.writer(response, delimiter=delimiter)
+       writer.writerow(["File loaded", datetime.datetime.now()])
+
+       if datatype == 'regtypes':
+               writer.writerow(['Type', 'Confirmed', 'Unconfirmed'])
+               for r in exec_to_list("SELECT regtype, count(payconfirmedat) AS confirmed, count(r.id) FILTER (WHERE payconfirmedat IS NULL) AS unconfirmed FROM confreg_conferenceregistration r RIGHT JOIN confreg_registrationtype rt ON rt.id=r.regtype_id WHERE rt.conference_id=%(confid)s GROUP BY rt.id ORDER BY rt.sortkey", { 'confid': conference.id, }):
+                       writer.writerow(_reencode_row(r))
+       elif datatype == 'discounts':
+               writer.writerow(['Code', 'Max uses', 'Confirmed', 'Unconfirmed'])
+               for r in exec_to_list("SELECT code, maxuses, count(payconfirmedat) AS confirmed, count(r.id) FILTER (WHERE payconfirmedat IS NULL) AS unconfirmed FROM confreg_conferenceregistration r RIGHT JOIN confreg_discountcode dc ON dc.code=r.vouchercode WHERE dc.conference_id=%(confid)s AND (r.conference_id=%(confid)s OR r.conference_id IS NULL) GROUP BY dc.id ORDER BY code", {'confid': conference.id, }):
+                       writer.writerow(_reencode_row(r))
+       elif datatype == 'vouchers':
+               writer.writerow(["Code", "Buyer", "Used", "Unused"])
+               for r in exec_to_list("SELECT b.buyername, count(v.user_id) AS used, count(*) FILTER (WHERE v.user_id IS NULL) AS unused FROM confreg_prepaidbatch b INNER JOIN confreg_prepaidvoucher v ON v.batch_id=b.id WHERE b.conference_id=%(confid)s GROUP BY b.id ORDER BY buyername", {'confid': conference.id, }):
+                       writer.writerow(_reencode_row(r))
+       elif datatype == 'sponsors':
+               (headers, data) = get_sponsor_dashboard_data(conference)
+               writer.writerow(headers)
+               for r in data:
+                       writer.writerow(_reencode_row(r))
+       else:
+               raise Http404()
+
+       return response
diff --git a/postgresqleu/confreg/migrations/0023_accesstokens.py b/postgresqleu/confreg/migrations/0023_accesstokens.py
new file mode 100644 (file)
index 0000000..0167ffd
--- /dev/null
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import postgresqleu.util.forms
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('confreg', '0022_ask_more_fields'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AccessToken',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('token', models.CharField(max_length=200)),
+                ('description', models.TextField()),
+                ('permissions', postgresqleu.util.forms.ChoiceArrayField(base_field=models.CharField(max_length=32, choices=[(b'regtypes', b'Registration types and counters'), (b'discounts', b'Discount codes'), (b'vouchers', b'Voucher codes'), (b'sponsors', b'Sponsors and counts')]), size=None)),
+                ('conference', models.ForeignKey(to='confreg.Conference')),
+            ],
+        ),
+        migrations.AlterUniqueTogether(
+            name='accesstoken',
+            unique_together=set([('conference', 'token')]),
+        ),
+    ]
index 98515084b6695d35400d5c1cf29b3552e4acb8c6..2303aa1171a93766d6f985c61d73936675ba3ece 100644 (file)
@@ -11,6 +11,7 @@ from django.utils.dateformat import DateFormat
 from django.contrib.postgres.fields import DateTimeRangeField
 
 from postgresqleu.util.validators import validate_lowercase
+from postgresqleu.util.forms import ChoiceArrayField
 
 from postgresqleu.confreg.dbimage import SpeakerImageStorage
 
@@ -903,3 +904,28 @@ class AggregatedDietary(models.Model):
 
        class Meta:
                unique_together = ( ('conference', 'dietary'), )
+
+
+AccessTokenPermissions = (
+       ('regtypes', 'Registration types and counters'),
+       ('discounts', 'Discount codes'),
+       ('vouchers', 'Voucher codes'),
+       ('sponsors', 'Sponsors and counts'),
+)
+
+class AccessToken(models.Model):
+       conference = models.ForeignKey(Conference, null=False, blank=False)
+       token = models.CharField(max_length=200, null=False, blank=False)
+       description = models.TextField(null=False, blank=False)
+       permissions = ChoiceArrayField(
+               models.CharField(max_length=32, blank=False, null=False, choices=AccessTokenPermissions)
+       )
+
+       class Meta:
+               unique_together = ( ('conference', 'token'), )
+
+       def __unicode__(self):
+               return self.token
+
+       def _display_permissions(self):
+               return ", ".join(self.permissions)
diff --git a/postgresqleu/confsponsor/util.py b/postgresqleu/confsponsor/util.py
new file mode 100644 (file)
index 0000000..5a81ef5
--- /dev/null
@@ -0,0 +1,7 @@
+from postgresqleu.util.db import exec_to_list
+
+def get_sponsor_dashboard_data(conference):
+       return (
+               ["Level", "Confirmed", "Unconfirmed"],
+               exec_to_list("SELECT l.levelname, count(s.id) FILTER (WHERE confirmed) AS confirmed, count(s.id) FILTER (WHERE NOT confirmed) AS unconfirmed FROM confsponsor_sponsorshiplevel l LEFT JOIN confsponsor_sponsor s ON s.level_id=l.id WHERE l.conference_id=%(confid)s GROUP BY l.id ORDER BY levelcost", {'confid': conference.id, })
+       )
index 1a824426815c8d7b991ee0af93c19e831acba34d..ab3d44ac0471fc67be3842b194a9f8a630fae3be 100644 (file)
@@ -150,9 +150,12 @@ urlpatterns = [
        url(r'^events/admin/(\w+)/volunteerslots/(.*/)?$', postgresqleu.confreg.backendviews.edit_volunteerslots),
        url(r'^events/admin/(\w+)/feedbackquestions/(.*/)?$', postgresqleu.confreg.backendviews.edit_feedbackquestions),
        url(r'^events/admin/(\w+)/discountcodes/(.*/)?$', postgresqleu.confreg.backendviews.edit_discountcodes),
+       url(r'^events/admin/(\w+)/accesstokens/(.*/)?$', postgresqleu.confreg.backendviews.edit_accesstokens),
        url(r'^events/admin/(\w+)/pendinginvoices/$', postgresqleu.confreg.backendviews.pendinginvoices),
        url(r'^events/admin/(\w+)/purgedata/$', postgresqleu.confreg.backendviews.purge_personal_data),
 
+       url(r'^events/admin/(\w+)/tokendata/([a-z0-9]{64})/(\w+)\.(tsv|csv)$', postgresqleu.confreg.backendviews.tokendata),
+
        url(r'^events/sponsor/', include('postgresqleu.confsponsor.urls')),
 
        # "Homepage" for events
index e712f585f710d0785f4125b6c2edd64753c48011..df307c77c5458799f596cadf1c5e84f16fd9c9e2 100644 (file)
@@ -74,6 +74,7 @@
 <h2>Metadata</h2>
 <div class="row">
   <div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/edit/">Conference entry</a></div>
+  <div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/accesstokens/">Access tokens</a></div>
   <div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/feedbackquestions/">Feedback questions</a></div>
 </div>
 <div class="row">