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
 Optional settings
 -----------------
 -----------------
 
 
@@ -78,6 +80,23 @@ Notification sender address:
 
 
     DEFAULT_FROM_EMAIL='notifier@example.tld'
     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)
 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>
 You can visit with your browser at <http://127.0.0.1:8000/map/contribute>
 
 
-Run production server
-=====================
-
-To be done
-
 Drop the database
 Drop the database
 =================
 =================
 
 
+If you want to **reset all your data**.
+
     $ rm db.sqlite3
     $ 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 -*-
 # -*- coding: utf-8 -*-
 
 
 from django.contrib import admin
 from django.contrib import admin
+from django.utils.html import format_html
 
 
 # Register your models here.
 # Register your models here.
 from .models import Contrib
 from .models import Contrib
@@ -14,7 +15,7 @@ admin.site.site_title = "Wifi with me"
 @admin.register(Contrib)
 @admin.register(Contrib)
 class ContribAdmin(admin.ModelAdmin):
 class ContribAdmin(admin.ModelAdmin):
     search_fields = ["name", "email", "phone"]
     search_fields = ["name", "email", "phone"]
-    list_display = ("name", "date", "phone", "email")
+    list_display = ("name", "date", "phone", "email", "expired_string")
 
 
     readonly_fields = ['date', 'expiration_date']
     readonly_fields = ['date', 'expiration_date']
     fieldsets = [
     fieldsets = [
@@ -43,3 +44,11 @@ class ContribAdmin(admin.ModelAdmin):
             'classes': ['collapse'],
             '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 -*-
 # -*- coding: utf-8 -*-
 
 
 from __future__ import unicode_literals
 from __future__ import unicode_literals
+from datetime import timedelta
 
 
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
+from django.conf import settings
 from django.db import models
 from django.db import models
 from django.utils import timezone
 from django.utils import timezone
 
 
@@ -10,6 +12,24 @@ from .fields import CommaSeparatedCharField
 from .utils import add_one_year, ANGLES, merge_intervals
 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):
 class Contrib(models.Model):
     CONTRIB_CONNECT = 'connect'
     CONTRIB_CONNECT = 'connect'
     CONTRIB_SHARE = 'share'
     CONTRIB_SHARE = 'share'
@@ -110,6 +130,8 @@ class Contrib(models.Model):
 
 
     PUBLIC_FIELDS = set(PRIVACY_MAP.keys())
     PUBLIC_FIELDS = set(PRIVACY_MAP.keys())
 
 
+    objects = ContribQuerySet.as_manager()
+
     def __str__(self):
     def __str__(self):
         return '#{} {}'.format(self.pk, self.name)
         return '#{} {}'.format(self.pk, self.name)
 
 
@@ -154,6 +176,9 @@ class Contrib(models.Model):
     def is_public(self):
     def is_public(self):
         return self.privacy_coordinates
         return self.privacy_coordinates
 
 
+    def is_expired(self):
+        return self.expiration_date <= timezone.now()
+
     def _may_be_public(self, field):
     def _may_be_public(self, field):
         return field in self.PUBLIC_FIELDS
         return field in self.PUBLIC_FIELDS
 
 
@@ -176,22 +201,27 @@ class Contrib(models.Model):
         else:
         else:
             return None
             return None
 
 
-    def get_absolute_url(self, request=None):
+    def get_absolute_url(self, request=None, base_url=None):
         """ Get absolute url
         """ 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(
         url = '{}#{}'.format(
             reverse('display_map'), self.pk)
             reverse('display_map'), self.pk)
         if request:
         if request:
             return request.build_absolute_uri(url)
             return request.build_absolute_uri(url)
+        elif base_url:
+            return '{}{}'.format(base_url, url)
         else:
         else:
             return url
             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.
 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 }}>
 <{{ management_link }}>
 
 
-Bien à toi,
+Bien à vous,
 
 
 Les bénévoles de {{ isp.NAME }}
 Les bénévoles de {{ isp.NAME }}

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

@@ -3,6 +3,7 @@ import json
 import warnings
 import warnings
 
 
 from django.core import mail
 from django.core import mail
+from django.core.management import call_command
 from django.core.signing import BadSignature
 from django.core.signing import BadSignature
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.test import TestCase, Client, override_settings
 from django.test import TestCase, Client, override_settings
@@ -89,6 +90,50 @@ class TestContribPrivacy(TestCase):
         self.assertEqual(c.get_public_field('name'), None)
         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):
 class TestViews(APITestCase):
     def mk_contrib_post_data(self, *args, **kwargs):
     def mk_contrib_post_data(self, *args, **kwargs):
         post_data = {
         post_data = {
@@ -354,3 +399,55 @@ class ContribTokenManagerTests(TestCase):
         self.assertEqual(
         self.assertEqual(
             manager.get_instance_if_allowed(token, contrib.pk),
             manager.get_instance_if_allowed(token, contrib.pk),
             contrib)
             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)
             mgmt_token = ContribTokenManager().mk_token(contrib)
 
 
             context = {
             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,
                 '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),
                 'permalink': contrib.get_absolute_url(request),
-                'isp':settings.ISP,
+                'isp': settings.ISP,
             }
             }
 
 
             # Send notification email to site administrator
             # Send notification email to site administrator
@@ -163,7 +162,7 @@ def thanks(request, token):
 
 
     return render(request, 'contribmap/thanks.html', {
     return render(request, 'contribmap/thanks.html', {
         'isp': settings.ISP,
         'isp': settings.ISP,
-        'management_link': contrib.make_management_url(token, request),
+        'management_link': contrib.make_management_url(token),
         'contrib': contrib,
         'contrib': contrib,
     })
     })
 
 

+ 10 - 0
wifiwithme/settings/base.py

@@ -137,3 +137,13 @@ STATICFILES_DIRS = [
 NOTIFICATION_EMAILS = []
 NOTIFICATION_EMAILS = []
 
 
 SITE_URL = 'http://example.com/wifi'
 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'