Browse Source

Merge branch 'jd-data-expiration' of FFDN/wifi-with-me into master

jocelyn 7 years ago
parent
commit
fb03211ca6

+ 34 - 6
README.md

@@ -49,6 +49,8 @@ You **must** Define some details about your ISP in the ISP variable, eg:
         }
     }
 
+    SITE_URL='https://wifi.faimaison.net
+
 Optional settings
 -----------------
 
@@ -78,6 +80,23 @@ Notification sender address:
 
     DEFAULT_FROM_EMAIL='notifier@example.tld'
 
+
+### Data expiration
+
+The data gets deleted after one year, if the contributing user does not give
+its explicit consent to keep it one more year.
+
+Reminders are sent to the contribution author when expiration date gets
+close. By default we send two notifications :
+
+    DATA_EXPIRATION_REMINDERS = [
+        30,  # 1 month before
+        7,   # 1 week before
+    ]
+
+You can tweak it to your will or decide to send no reminder (with value `[]`).
+
+
 Migrate from bottle version (optional)
 ======================================
 
@@ -104,14 +123,23 @@ Then launch service with:
 
 You can visit with your browser at <http://127.0.0.1:8000/map/contribute>
 
-Run production server
-=====================
-
-To be done
-
 Drop the database
 =================
 
+If you want to **reset all your data**.
+
     $ rm db.sqlite3
 
-What else ?
+
+Run production server
+=====================
+
+(Pretty rough instructions. Feel free to submit patches)
+
+1. Deploy it [like any django site](https://docs.djangoproject.com/en/1.11/howto/deployment/)
+2. Customize [mandatory and optional settings](#set-up-configuration)
+3. Customize `SECRET_KEY` to something really random. Hint: `python -c "import string,random; uni=string.ascii_letters+string.digits+string.punctuation; print(repr(''.join([random.SystemRandom().choice(uni) for i in range(random.randint(45,50))])))"`
+4. Set *daily* crons for the two commands that take care of data expiration
+   handling (run them with `--help` for more information):
+    - `./manage.py delete_expired_contribs`
+    - `./manage.py send_expiration_reminders`

+ 10 - 1
wifiwithme/apps/contribmap/admin.py

@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 
 from django.contrib import admin
+from django.utils.html import format_html
 
 # Register your models here.
 from .models import Contrib
@@ -14,7 +15,7 @@ admin.site.site_title = "Wifi with me"
 @admin.register(Contrib)
 class ContribAdmin(admin.ModelAdmin):
     search_fields = ["name", "email", "phone"]
-    list_display = ("name", "date", "phone", "email")
+    list_display = ("name", "date", "phone", "email", "expired_string")
 
     readonly_fields = ['date', 'expiration_date']
     fieldsets = [
@@ -43,3 +44,11 @@ class ContribAdmin(admin.ModelAdmin):
             'classes': ['collapse'],
         }]
     ]
+
+    def expired_string(self, obj):
+        if obj.is_expired():
+            return format_html('<strong style="color: red; cursor: help;" title="Cette entrée excède la durée de rétention et aurait dû être supprimée automatiquement.">expiré</strong>')
+        else:
+            return 'non expiré'
+
+    expired_string.short_description = 'Expiration'

+ 0 - 0
wifiwithme/apps/contribmap/management/__init__.py


+ 0 - 0
wifiwithme/apps/contribmap/management/commands/__init__.py


+ 32 - 0
wifiwithme/apps/contribmap/management/commands/delete_expired_contribs.py

@@ -0,0 +1,32 @@
+"""Delete expired contributions
+
+Based on the expiration date that exists on any contrib. Produce no output by
+default (cron stuff)
+
+"""
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+from ...models import Contrib
+
+
+class Command(BaseCommand):
+    help = __doc__
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            '--dry-run', default=False, action='store_true',
+            help="Do not actually delete contributions, just print which ones should be")
+
+    def handle(self, dry_run, *args, **options):
+        if dry_run:
+            self.stderr.write('DRY RUN MODE: we do not actually delete contributions.\n')
+        for contrib in Contrib.objects.expired():
+            if not dry_run:
+                contrib.delete()
+            else:
+                self._log_deleted_contrib(contrib)
+
+    def _log_deleted_contrib(self, contrib):
+        self.stderr.write("Would delete expired contribution {}\n".format(contrib))

+ 77 - 0
wifiwithme/apps/contribmap/management/commands/send_expiration_reminders.py

@@ -0,0 +1,77 @@
+""" Send reminders for contribution that are about to expire
+
+It offers a way for contributors to opt-in keeping the data one year more (or
+deleting it right now).
+
+Reminders are sent when the script is called exactly `n` days before the
+expiration date.
+
+This `n` can be configured via `DATA_EXPIRATION_REMINDERS` setting.
+"""
+
+from django.conf import settings
+from django.core.mail import send_mail
+from django.core.management.base import BaseCommand, CommandError
+from django.template.loader import get_template
+from django.utils.translation import activate
+
+from ...models import Contrib
+from ...tokens import ContribTokenManager
+
+
+class Command(BaseCommand):
+    help = __doc__
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            '--dry-run', default=False, action='store_true',
+            help="Do not actually send emails, just pretend to")
+
+    def handle(self, dry_run, *args, **options):
+        if dry_run:
+            self.stderr.write('DRY RUN MODE: we do not actually send emails.')
+
+        for ndays in settings.DATA_EXPIRATION_REMINDERS:
+            expirating_contribs = Contrib.objects.expires_in_days(ndays)
+            for contrib in expirating_contribs:
+                if not contrib.email:
+                    self._warn_no_email(contrib, ndays)
+                else:
+                    if not dry_run:
+                        self.send_expiration_reminder(contrib, ndays)
+                    self._log_sent_email(contrib, ndays)
+
+    def _warn_no_email(self, contrib, ndays):
+        self.stderr.write(
+            'WARNING : the contribution {} is about to expire in {} days but'
+            + 'do not define a contact email.'.format(
+                contrib, ndays))
+
+    def _log_sent_email(self, contrib, ndays):
+        self.stderr.write(
+            "Sent reminder email to for {} expiration in {} days".format(
+                contrib, ndays))
+
+    def send_expiration_reminder(self, contrib, ndays):
+        subject = get_template(
+            'contribmap/mails/expiration_reminder.subject')
+        body = get_template(
+            'contribmap/mails/expiration_reminder.txt')
+
+        mgmt_token = ContribTokenManager().mk_token(contrib)
+
+        context = {
+            'contrib': contrib,
+            'ndays': ndays,
+            'isp': settings.ISP,
+            'management_link': contrib.make_management_url(mgmt_token),
+        }
+
+        # Without that, getting month & day names in english
+        activate(settings.LANGUAGE_CODE)
+        send_mail(
+            subject.render(context),
+            body.render(context),
+            settings.DEFAULT_FROM_EMAIL,
+            [contrib.email],
+        )

+ 39 - 9
wifiwithme/apps/contribmap/models.py

@@ -1,8 +1,10 @@
 # -*- coding: utf-8 -*-
 
 from __future__ import unicode_literals
+from datetime import timedelta
 
 from django.core.urlresolvers import reverse
+from django.conf import settings
 from django.db import models
 from django.utils import timezone
 
@@ -10,6 +12,24 @@ from .fields import CommaSeparatedCharField
 from .utils import add_one_year, ANGLES, merge_intervals
 
 
+class ContribQuerySet(models.query.QuerySet):
+    def expired(self):
+        return self.filter(expiration_date__lte=timezone.now())
+
+    def expired_in_days(self, ndays):
+        return self.filter(
+            expiration_date__lte=timezone.now() + timedelta(days=ndays))
+
+    def expires_in_days(self, ndays):
+        """ Returns only the data expiring in exactly that number of days
+
+        Think about it as an anniversary. This function ignores
+        minutes/seconds/hours and check only days.
+        """
+        return self.filter(
+            expiration_date__date=timezone.now() + timedelta(days=ndays))
+
+
 class Contrib(models.Model):
     CONTRIB_CONNECT = 'connect'
     CONTRIB_SHARE = 'share'
@@ -110,6 +130,8 @@ class Contrib(models.Model):
 
     PUBLIC_FIELDS = set(PRIVACY_MAP.keys())
 
+    objects = ContribQuerySet.as_manager()
+
     def __str__(self):
         return '#{} {}'.format(self.pk, self.name)
 
@@ -154,6 +176,9 @@ class Contrib(models.Model):
     def is_public(self):
         return self.privacy_coordinates
 
+    def is_expired(self):
+        return self.expiration_date <= timezone.now()
+
     def _may_be_public(self, field):
         return field in self.PUBLIC_FIELDS
 
@@ -176,22 +201,27 @@ class Contrib(models.Model):
         else:
             return None
 
-    def get_absolute_url(self, request=None):
+    def get_absolute_url(self, request=None, base_url=None):
         """ Get absolute url
 
-        :type param: request
-        :param: if mentioned, will be used to provide a full URL (starting with
-        "http://" or "https://")
+        You can mention either `request` or `base_url` to get a full URL
+        (starting with "http://" or "https://")
+
+        :type request: request
+        :param request: if mentioned, will be used to provide a full URL
+        :param base_url: if mentioned, will be used to provide a full URL
         """
         url = '{}#{}'.format(
             reverse('display_map'), self.pk)
         if request:
             return request.build_absolute_uri(url)
+        elif base_url:
+            return '{}{}'.format(base_url, url)
         else:
             return url
 
-    def make_management_url(self, token, request):
-        return request.build_absolute_uri(
-            '{}?token={}'.format(
-                reverse('manage_contrib', kwargs={'pk': self.pk}),
-                token))
+    def make_management_url(self, token):
+        return '{}{}?token={}'.format(
+            settings.SITE_URL.strip('/'),
+            reverse('manage_contrib', kwargs={'pk': self.pk}),
+            token)

+ 1 - 0
wifiwithme/apps/contribmap/templates/contribmap/mails/expiration_reminder.subject

@@ -0,0 +1 @@
+[wifi-with-me] Votre demande #{{ contrib.id }} expire dans {{ ndays }} jours

+ 18 - 0
wifiwithme/apps/contribmap/templates/contribmap/mails/expiration_reminder.txt

@@ -0,0 +1,18 @@
+Chèr·e {{ contrib.name }},
+
+Vous aviez déposé le {{ contrib.date|date:'j F o' }} une demande de raccordement.
+
+Sans intervention de votre part, votre demande, ainsi que les informations
+personelles associées seront supprimés de nos serveurs dans {{ ndays }} jours :
+le **{{contrib.expiration_date|date }}**{% if isp.CNIL.LINK %}, conformément à notre déclaration CNIL¹{% endif %}.
+
+Si vous souhaitez prolonger votre demande ou la supprimer immédiatement, vous
+pouvez utiliser le lien privé ci-dessous :
+
+<{{ management_link }}>
+
+Bien à vous,
+
+Les bénévoles de {{ isp.NAME }}
+
+{% if isp.CNIL.LINK %}¹ {{ isp.CNIL.LINK }}{% endif %}

+ 2 - 2
wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_author_notice.txt

@@ -4,10 +4,10 @@ Votre demande a bien été enregistrée. Elle est en ligne publiquement à l'adr
 
 Si tout ou partie des informations n'apparaissent pas, c'est que vous avez choisi qu'elles ne soient pas publiques.
 
-Vous pouvez gérer ou supprimer ta demande grace à ce lien privé à conserver :
+Vous pouvez gérer ou supprimer votre demande grâce à ce lien privé à conserver :
 
 <{{ management_link }}>
 
-Bien à toi,
+Bien à vous,
 
 Les bénévoles de {{ isp.NAME }}

+ 97 - 0
wifiwithme/apps/contribmap/tests.py

@@ -3,6 +3,7 @@ import json
 import warnings
 
 from django.core import mail
+from django.core.management import call_command
 from django.core.signing import BadSignature
 from django.contrib.auth.models import User
 from django.test import TestCase, Client, override_settings
@@ -89,6 +90,50 @@ class TestContribPrivacy(TestCase):
         self.assertEqual(c.get_public_field('name'), None)
 
 
+class TestContribQuerySet(TestCase):
+    def test_expired(self):
+        with freeze_time('12-11-2100', tz_offset=0):
+            Contrib.objects.create(
+                name='foo', orientations=['S'],
+                contrib_type=Contrib.CONTRIB_CONNECT,
+                latitude=0.5, longitude=0.5)
+        # one year and one month later
+        with freeze_time('12-12-2101', tz_offset=0):
+            Contrib.objects.create(
+                name='bar', orientations=['S'],
+                contrib_type=Contrib.CONTRIB_CONNECT,
+                latitude=0.5, longitude=0.5)
+
+            expired = Contrib.objects.expired()
+            self.assertEqual(expired.count(), 1)
+            self.assertEqual(expired.first().name, 'foo')
+
+    def test_expired_in_days(self):
+        Contrib.objects.create(
+            name='foo', orientations=['S'],
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            latitude=0.5, longitude=0.5)
+
+        self.assertEqual(Contrib.objects.expired_in_days(0).count(), 0)
+        self.assertEqual(Contrib.objects.expired_in_days(366).count(), 1)
+
+    def test_expires_in_days(self):
+        with freeze_time('12-11-2101 12:00', tz_offset=0):
+            Contrib.objects.create(
+                name='foo', orientations=['S'],
+                contrib_type=Contrib.CONTRIB_CONNECT,
+                latitude=0.5, longitude=0.5)
+            self.assertEqual(Contrib.objects.expires_in_days(364).count(), 0)
+            self.assertEqual(Contrib.objects.expires_in_days(365).count(), 1)
+            self.assertEqual(Contrib.objects.expires_in_days(366).count(), 0)
+
+        # One year, one hour and two minutes later
+        # (check that minutes/hours are ignored)
+        with freeze_time('12-11-2101 13:02', tz_offset=0):
+            self.assertEqual(Contrib.objects.expires_in_days(365).count(), 1)
+
+
+
 class TestViews(APITestCase):
     def mk_contrib_post_data(self, *args, **kwargs):
         post_data = {
@@ -354,3 +399,55 @@ class ContribTokenManagerTests(TestCase):
         self.assertEqual(
             manager.get_instance_if_allowed(token, contrib.pk),
             contrib)
+
+
+class TestManagementCommands(TestCase):
+    def setUp(self):
+        contrib = Contrib.objects.create(
+            name='John',
+            email='foo@example.com',
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            latitude=0.5,
+            longitude=0.5,
+        )
+        contrib.expiration_date = datetime.datetime(
+            2010, 10, 10, 1, 0, tzinfo=pytz.utc)
+        contrib.save()
+
+    @override_settings(DATA_EXPIRATION_REMINDERS=[10])
+    def test_send_expiration_reminders(self):
+        # 11 days before (should not send)
+        with freeze_time('29-09-2010', tz_offset=0):
+            call_command('send_expiration_reminders')
+            self.assertEqual(Contrib.objects.count(), 1)
+
+        # 9 days before (should not send)
+        with freeze_time('01-10-2010', tz_offset=0):
+            call_command('send_expiration_reminders')
+            self.assertEqual(Contrib.objects.count(), 1)
+
+        # 10 days before (should send)
+        with freeze_time('30-09-2010', tz_offset=0):
+            call_command('send_expiration_reminders', '--dry-run')
+            self.assertEqual(Contrib.objects.count(), 1)
+            call_command('send_expiration_reminders')
+            self.assertEqual(len(mail.outbox), 1)
+
+    def test_delete_expired_contribs(self):
+        # 1 days before expiration
+        with freeze_time('09-09-2010', tz_offset=0):
+            call_command('delete_expired_contribs')
+            self.assertEqual(Contrib.objects.count(), 1)
+
+        # expiration day
+        with freeze_time('10-10-2010 23:59', tz_offset=0):
+            call_command('delete_expired_contribs', '--dry-run')
+            self.assertEqual(Contrib.objects.count(), 1)
+            call_command('delete_expired_contribs')
+            self.assertEqual(Contrib.objects.count(), 0)
+
+        self.setUp()
+        # 1 day after expiration
+        with freeze_time('11-10-2010', tz_offset=0):
+            call_command('delete_expired_contribs')
+            self.assertEqual(Contrib.objects.count(), 0)

+ 5 - 6
wifiwithme/apps/contribmap/views.py

@@ -32,13 +32,12 @@ def add_contrib(request):
             mgmt_token = ContribTokenManager().mk_token(contrib)
 
             context = {
-                'site_url': (settings.SITE_URL + reverse('display_map')
-                             + '#{}'.format(contrib.id)),
+                'site_url': contrib.get_absolute_url(
+                    base_url=settings.SITE_URL),
                 'contrib': contrib,
-                'management_link': contrib.make_management_url(
-                    mgmt_token, request),
+                'management_link': contrib.make_management_url(mgmt_token),
                 'permalink': contrib.get_absolute_url(request),
-                'isp':settings.ISP,
+                'isp': settings.ISP,
             }
 
             # Send notification email to site administrator
@@ -163,7 +162,7 @@ def thanks(request, token):
 
     return render(request, 'contribmap/thanks.html', {
         'isp': settings.ISP,
-        'management_link': contrib.make_management_url(token, request),
+        'management_link': contrib.make_management_url(token),
         'contrib': contrib,
     })
 

+ 10 - 0
wifiwithme/settings/base.py

@@ -137,3 +137,13 @@ STATICFILES_DIRS = [
 NOTIFICATION_EMAILS = []
 
 SITE_URL = 'http://example.com/wifi'
+
+
+# Data expiration warning
+
+DATA_EXPIRATION_REMINDERS = [
+    30,  # 1 month before
+    7,   # 1 week before
+]
+
+SITE_URL = 'http://localhost:8000'