Support purging of personal data from conference registrations
authorMagnus Hagander <magnus@hagander.net>
Thu, 26 Apr 2018 19:23:34 +0000 (21:23 +0200)
committerMagnus Hagander <magnus@hagander.net>
Fri, 27 Apr 2018 13:59:08 +0000 (15:59 +0200)
Once a conference is in the past, purge physical addresses, phone
numbers, dietary needs and tshirt sizes from registrations, since they
can be considered personal data and we shouldn't keep that around.

Before purging, aggregate values for tshirt sizes and dietary needs in
an anonymous way, to make sure we can look back at past events to get
statistics for future ones.

postgresqleu/confreg/backendviews.py
postgresqleu/confreg/management/commands/confreg_warn_purge.py [new file with mode: 0644]
postgresqleu/confreg/migrations/0019_purge_personal_data.py [new file with mode: 0644]
postgresqleu/confreg/models.py
postgresqleu/urls.py
template/confreg/admin_dashboard_single.html
template/confreg/admin_purge_personal_data.html [new file with mode: 0644]

index 3ed14138302fa9ddd6e201949b4ed1774eb3cf21..151d9667320f7ca92f94f010912f84936e121b43 100644 (file)
@@ -9,8 +9,10 @@ from django.contrib.auth.decorators import login_required
 from django.conf import settings
 
 import urllib
+import datetime
 
 from postgresqleu.util.middleware import RedirectException
+from postgresqleu.util.db import exec_to_dict, exec_no_result
 
 from models import Conference, ConferenceRegistration
 from models import RegistrationType, RegistrationClass
@@ -385,3 +387,33 @@ def pendinginvoices(request, urlname):
                        'Sponsor invoices': Invoice.objects.filter(paidat__isnull=True, sponsor__conference=conference),
                },
        })
+
+
+@transaction.atomic
+def purge_personal_data(request, urlname):
+       conference = get_authenticated_conference(request, urlname)
+
+       if conference.personal_data_purged:
+               messages.warning(request, 'Personal data for this conference has already been purged')
+               return HttpResponseRedirect('../')
+
+       if request.method == 'POST':
+               exec_no_result("INSERT INTO confreg_aggregatedtshirtsizes (conference_id, size_id, num) SELECT conference_id, shirtsize_id, count(*) FROM confreg_conferenceregistration WHERE conference_id=%(confid)s AND shirtsize_id IS NOT NULL GROUP BY conference_id, shirtsize_id", {'confid': conference.id, })
+               exec_no_result("INSERT INTO confreg_aggregateddietary (conference_id, dietary, num) SELECT conference_id, lower(dietary), count(*) FROM confreg_conferenceregistration WHERE conference_id=%(confid)s AND dietary IS NOT NULL AND dietary != '' GROUP BY conference_id, lower(dietary)", {'confid': conference.id, })
+               exec_no_result("UPDATE confreg_conferenceregistration SET shirtsize_id=NULL, dietary='', phone='', address='' WHERE conference_id=%(confid)s", {'confid': conference.id, })
+               conference.personal_data_purged = datetime.datetime.now()
+               conference.save()
+               messages.info(request, "Personal data purged from conference")
+               return HttpResponseRedirect('../')
+
+       return render(request, 'confreg/admin_purge_personal_data.html', {
+               'conference': conference,
+               'counts': exec_to_dict("""SELECT
+  count(1) FILTER (WHERE shirtsize_id IS NOT NULL) AS "T-shirt size registrations",
+  count(1) FILTER (WHERE dietary IS NOT NULL AND dietary != '') AS "Dietary needs",
+  count(1) FILTER (WHERE phone IS NOT NULL AND phone != '') AS "Phone numbers",
+  count(1) FILTER (WHERE address IS NOT NULL AND address != '') AS "Addresses"
+FROM confreg_conferenceregistration WHERE conference_id=%(confid)s""", {
+       'confid': conference.id,
+               })[0],
+       })
diff --git a/postgresqleu/confreg/management/commands/confreg_warn_purge.py b/postgresqleu/confreg/management/commands/confreg_warn_purge.py
new file mode 100644 (file)
index 0000000..d02c7c7
--- /dev/null
@@ -0,0 +1,41 @@
+#
+# Send warnings when a conference has not purged their personal
+# data long after the conference ended.
+#
+# Intended to run on a weekly basis or so, as it will keep repeating
+# the reminders every time.
+#
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+from django.conf import settings
+
+from datetime import datetime, timedelta
+
+from postgresqleu.mailqueue.util import send_simple_mail
+
+from postgresqleu.confreg.models import Conference
+
+class Command(BaseCommand):
+       help = 'Send warnings about purging personal data'
+
+       def handle(self, *args, **options):
+               for conference in Conference.objects.filter(personal_data_purged__isnull=True,
+                                                                                                       enddate__lt=datetime.now() - timedelta(days=30)) \
+                                                                                                       .extra(where=["EXISTS (SELECT 1 FROM confreg_conferenceregistration r WHERE r.conference_id=confreg_conference.id)"]):
+                       send_simple_mail(conference.contactaddr,
+                                                        conference.contactaddr,
+                                                        "{0}: time to purge personal data?".format(conference.conferencename),
+                                                        """Conference {0} finished on {1},
+but personal data has not been purged.
+
+In accordance with the rules, personal data should be purged
+as soon as it's no longer needed. So please consider doing so,
+from the conference dashboard:
+
+{2}/events/admin/{3}/
+""".format(conference.conferencename, conference.enddate, settings.SITEBASE, conference.urlname),
+                                                        sendername = conference.conferencename,
+                                                        receivername = conference.conferencename,
+                                                        bcc = settings.ADMINS[0][1],
+                       )
+
diff --git a/postgresqleu/confreg/migrations/0019_purge_personal_data.py b/postgresqleu/confreg/migrations/0019_purge_personal_data.py
new file mode 100644 (file)
index 0000000..91805df
--- /dev/null
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('confreg', '0018_constraint_reg_payments'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AggregatedDietary',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('dietary', models.CharField(max_length=100)),
+                ('num', models.IntegerField()),
+            ],
+        ),
+        migrations.CreateModel(
+            name='AggregatedTshirtSizes',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('num', models.IntegerField()),
+            ],
+        ),
+        migrations.AddField(
+            model_name='conference',
+            name='personal_data_purged',
+            field=models.DateTimeField(help_text=b'Personal data for registrations for this conference have been purged', null=True, blank=True),
+        ),
+        migrations.AddField(
+            model_name='aggregatedtshirtsizes',
+            name='conference',
+            field=models.ForeignKey(to='confreg.Conference'),
+        ),
+        migrations.AddField(
+            model_name='aggregatedtshirtsizes',
+            name='size',
+            field=models.ForeignKey(to='confreg.ShirtSize'),
+        ),
+        migrations.AddField(
+            model_name='aggregateddietary',
+            name='conference',
+            field=models.ForeignKey(to='confreg.Conference'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='aggregatedtshirtsizes',
+            unique_together=set([('conference', 'size')]),
+        ),
+        migrations.AlterUniqueTogether(
+            name='aggregateddietary',
+            unique_together=set([('conference', 'dietary')]),
+        ),
+    ]
index c53efc19567d6d6f12438d28a0e6f3c48845d357..8de5b757fdbd060c339d85dc57ddaafddafcd64c 100644 (file)
@@ -142,6 +142,7 @@ class Conference(models.Model):
        invoice_autocancel_hours = models.IntegerField(blank=True, null=True, validators=[MinValueValidator(1),], verbose_name="Autocancel invoices", help_text="Automatically cancel invoices after this many hours")
        attendees_before_waitlist = models.IntegerField(blank=False, null=False, default=0, validators=[MinValueValidator(0),], verbose_name="Attendees before waitlist", help_text="Maximum number of attendees before enabling waitlist management. 0 for no waitlist management")
        series = models.ForeignKey(ConferenceSeries, null=False, blank=False)
+       personal_data_purged = models.DateTimeField(null=True, blank=True, help_text="Personal data for registrations for this conference have been purged")
 
        # Attributes that are safe to access in jinja templates
        _safe_attributes = ('active', 'askfood', 'askshareemail', 'asktshirt',
@@ -191,6 +192,10 @@ class Conference(models.Model):
 
                return False
 
+       @property
+       def needs_data_purge(self):
+               return self.enddate < datetime.date.today() and not self.personal_data_purged
+
        def clean(self):
                cc = super(Conference, self).clean()
                if self.sendwelcomemail and not self.welcomemail:
@@ -874,3 +879,21 @@ class PendingAdditionalOrder(models.Model):
 
        def __unicode__(self):
                return u"%s" % (self.reg, )
+
+
+
+class AggregatedTshirtSizes(models.Model):
+       conference = models.ForeignKey(Conference, null=False, blank=False)
+       size = models.ForeignKey(ShirtSize, null=False, blank=False)
+       num = models.IntegerField(null=False, blank=False)
+
+       class Meta:
+               unique_together = ( ('conference', 'size'), )
+
+class AggregatedDietary(models.Model):
+       conference = models.ForeignKey(Conference, null=False, blank=False)
+       dietary = models.CharField(max_length=100, null=False, blank=False)
+       num = models.IntegerField(null=False, blank=False)
+
+       class Meta:
+               unique_together = ( ('conference', 'dietary'), )
index abad489cf8f582bffa02a36a7f334e7c7215d699..bf20b0630143e0e786c2e45739f166f5ac7b22bb 100644 (file)
@@ -151,6 +151,7 @@ urlpatterns = [
        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+)/pendinginvoices/$', postgresqleu.confreg.backendviews.pendinginvoices),
+       url(r'^events/admin/(\w+)/purgedata/$', postgresqleu.confreg.backendviews.purge_personal_data),
 
        url(r'^events/sponsor/', include('postgresqleu.confsponsor.urls')),
 
index b706cea29c0db0726feb9568bfac1c8bb988f4ca..e712f585f710d0785f4125b6c2edd64753c48011 100644 (file)
@@ -7,6 +7,16 @@
   <span class="label label-default">{{c.startdate|date:"Y-m-d"}}</span> - <span class="label label-default">{{c.enddate|date:"Y-m-d"}}</span>
 </div>
 
+{%if c.needs_data_purge %}
+<h2>Purge personal data</h2>
+<p>
+  Once we don't need it anymore, personal data should be purged/aggregated for the conference.
+</p>
+<div class="row">
+  <div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-danger btn-block" href="/events/admin/{{c.urlname}}/purgedata/">Purge personal data</a></div>
+</div>
+{%endif%}
+
 <h2>Registrations</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}}/regdashboard/">Registration dashboard</a></div>
diff --git a/template/confreg/admin_purge_personal_data.html b/template/confreg/admin_purge_personal_data.html
new file mode 100644 (file)
index 0000000..4178ab9
--- /dev/null
@@ -0,0 +1,24 @@
+{%extends "confreg/confadmin_base.html"%}
+{%block title%}Purge personal data{%endblock%}
+
+{%block layoutblock%}
+<h1>Purge personal data</h1>
+<p>
+  You are about to purge the following data:
+</p>
+<div class="row"><div class="col-sm-6">
+<table class="table">
+{%for k,v in counts.items%}
+  <tr>
+    <th scope="col">{{k}}</th>
+    <td>{{v}}</td>
+  </tr>
+{%endfor%}
+</table>
+</div></div>
+
+<form class="form-horizontal" method="POST" action=".">{%csrf_token%}
+  <input type="submit" class="btn btn-default" value="Purge and summarize data">
+</form>
+
+{%endblock%}