37 Commits ee8d2da48c ... 8193b9c9c1

Author SHA1 Message Date
  vross 8193b9c9c1 comment with link to photos required for new connection request 6 years ago
  Jocelyn Delalande b625cbf5a2 Show moderator emails with default dev settings 6 years ago
  Jocelyn Delalande e13fa12b49 Fix bug preventing contrib URl to appear in moderator notifications 6 years ago
  jocelyn fb03211ca6 Merge branch 'jd-data-expiration' of FFDN/wifi-with-me into master 7 years ago
  Jocelyn Delalande 1e50dd0ad0 Fix language in notification email 7 years ago
  Jocelyn Delalande 511bc0eb82 Fix reminder notice text 7 years ago
  Jocelyn Delalande f77e6f13e0 Mention in admin list that a contribution is expired 7 years ago
  Jocelyn Delalande 18a98a6c5e Fix typos 7 years ago
  Jocelyn Delalande ecfca618ac Document quickly production deployment. 7 years ago
  Jocelyn Delalande ef6b359ae4 Warn about database deletion. 7 years ago
  Jocelyn Delalande 77510ecd7c Add a command to delete expired contributions 7 years ago
  Jocelyn Delalande 8c4f4f1fbd Command to send reminders on close data expiration 7 years ago
  Jocelyn Delalande 7522bc7669 Extend Contrib.get_absolute_url to accept a base URL 7 years ago
  Jocelyn Delalande f2218a349d Add QuerySet methods to check data expiration 7 years ago
  Jocelyn Delalande a268413a35 Fix login url failing on NoReverseMatch 7 years ago
  jocelyn a9655849f4 Merge branch 'fix-readme' of daimrod/wifi-with-me into master 7 years ago
  Jocelyn Delalande c8702a22f1 Fix radio buttons display 7 years ago
  Grégoire Jadi 4e61167cb4 Create a superuser before using the service 7 years ago
  Grégoire Jadi e75a454ad8 Add missing 'with' in README 7 years ago
  Jocelyn Delalande 9b6c7dcc28 Upgrade to Django 1.11 7 years ago
  jocelyn c323d00996 Merge branch 'jd-contribution-manage-link' of FFDN/wifi-with-me into master 7 years ago
  Jocelyn Delalande 3e3c914503 Use « vous » on the user notification email 7 years ago
  vross b8a4f21dfa Change wording for clarity 7 years ago
  Jocelyn Delalande 339883d926 Sends a confirmation email to the contributor 7 years ago
  Jocelyn Delalande 868e62db9c Show secret management link after contrib submission 7 years ago
  Jocelyn Delalande 57e7c047f3 Add contribution management page for end-user 7 years ago
  Jocelyn Delalande 0019e280ed Remove duplicate code in tests 7 years ago
  Jocelyn Delalande f8c620b6ac Show django messages framework messages appear 7 years ago
  Jocelyn Delalande aed60757ed Add URL-token authorization tools 7 years ago
  Jocelyn Delalande 6b8057ef5e Add a "show on site" link in admin contrib forms 7 years ago
  Jocelyn Delalande d07757fa44 Show dates in contrib admin form 7 years ago
  Jocelyn Delalande a43fea9f14 Add an expiration date to contribs 7 years ago
  Jocelyn Delalande 0390e8bd57 Hide systematic warning running tests 7 years ago
  Jocelyn Delalande e6f56da843 Reword notification → admin notification 7 years ago
  jocelyn 15a4581f6b Merge branch 'master' of vross/wifi-with-me into master 7 years ago
  vross d0cd5fbfd6 Send the URL of the new request's map marker in the e-mail notification 7 years ago
  jocelyn 5134395936 Merge branch 'prod' of tetaneutral.net/wifi-with-me into master 7 years ago
35 changed files with 1004 additions and 86 deletions
  1. 39 7
      README.md
  2. 2 1
      requirements/base.txt
  3. 1 0
      requirements/dev.txt
  4. 16 2
      wifiwithme/apps/contribmap/admin.py
  5. 21 0
      wifiwithme/apps/contribmap/forms.py
  6. 0 0
      wifiwithme/apps/contribmap/management/__init__.py
  7. 0 0
      wifiwithme/apps/contribmap/management/commands/__init__.py
  8. 32 0
      wifiwithme/apps/contribmap/management/commands/delete_expired_contribs.py
  9. 77 0
      wifiwithme/apps/contribmap/management/commands/send_expiration_reminders.py
  10. 20 0
      wifiwithme/apps/contribmap/migrations/0015_contrib_expiration_date.py
  11. 24 0
      wifiwithme/apps/contribmap/migrations/0016_set_expiration_date.py
  12. 20 0
      wifiwithme/apps/contribmap/migrations/0017_auto_20170801_1610.py
  13. 25 0
      wifiwithme/apps/contribmap/migrations/0018_auto_20171016_2200.py
  14. 81 2
      wifiwithme/apps/contribmap/models.py
  15. 7 0
      wifiwithme/apps/contribmap/templates/base.html
  16. 1 0
      wifiwithme/apps/contribmap/templates/contribmap/mails/expiration_reminder.subject
  17. 18 0
      wifiwithme/apps/contribmap/templates/contribmap/mails/expiration_reminder.txt
  18. 1 0
      wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_author_notice.subject
  19. 13 0
      wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_author_notice.txt
  20. 0 0
      wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_moderator_notice.subject
  21. 1 1
      wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_notice.txt
  22. 66 0
      wifiwithme/apps/contribmap/templates/contribmap/manage_contrib.html
  23. 13 0
      wifiwithme/apps/contribmap/templates/contribmap/thanks.html
  24. 2 2
      wifiwithme/apps/contribmap/templates/contribmap/wifi-form.html
  25. 262 49
      wifiwithme/apps/contribmap/tests.py
  26. 81 0
      wifiwithme/apps/contribmap/tokens.py
  27. 5 2
      wifiwithme/apps/contribmap/urls.py
  28. 17 0
      wifiwithme/apps/contribmap/utils.py
  29. 117 18
      wifiwithme/apps/contribmap/views.py
  30. 1 1
      wifiwithme/core/templates/registration/login.html
  31. 10 0
      wifiwithme/settings/base.py
  32. 2 0
      wifiwithme/settings/dev.py
  33. 14 0
      wifiwithme/static/confirmation.js
  34. 1 1
      wifiwithme/static/main.css
  35. 14 0
      wifiwithme/static/minimap.js

+ 39 - 7
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)
 ======================================
 
@@ -94,20 +113,33 @@ It is required to initialize database first:
 
     $ ./manage.py migrate
 
-Then launch service with:
+Create an admin:
 
-    $ ./manage.py runserver
+    $ ./manage.py createsuperuser
 
-You can visit your browser at <http://127.0.0.1:8000/map/contribute>
+Then launch service with:
 
-Run production server
-=====================
+    $ ./manage.py runserver
 
-To be done
+You can visit with your browser at <http://127.0.0.1:8000/map/contribute>
 
 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`

+ 2 - 1
requirements/base.txt

@@ -1,4 +1,5 @@
-Django>=1.9.3,<1.10
+Django>=1.11,<2
 PyYAML>=3.11,<4.0
+django-request-token>=0.6,<0.7
 pytz
 sqlparse

+ 1 - 0
requirements/dev.txt

@@ -1,2 +1,3 @@
 -r base.txt
 django-debug-toolbar
+freezegun

+ 16 - 2
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,11 +15,16 @@ 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 = [
         [None, {
-            'fields': [('name', 'contrib_type'), 'comment', 'email', 'phone'],
+            'fields': [
+                ('name', 'contrib_type'),
+                'comment', 'email', 'phone',
+                ('date', 'expiration_date'),
+            ],
         }],
         ['Localisation', {
             'fields': [
@@ -38,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'

+ 21 - 0
wifiwithme/apps/contribmap/forms.py

@@ -2,6 +2,8 @@ from django import forms
 
 from .models import Contrib
 
+import re
+
 
 ORIENTATIONS = (
     ('N', 'Nord'),
@@ -72,11 +74,18 @@ class PublicContribForm(forms.ModelForm):
             if data.get('access_type') == '':
                 self.add_error('access_type', 'Ce champ est requis')
 
+    #tetaneutral.net: comment with link to photos required for new connection request
+    def _validate_comment(self, data):
+        if data.get('contrib_type') == Contrib.CONTRIB_CONNECT and re.search('https?://', data.get('comment')) is None:
+            self.add_error('comment', 'Un lien vers les photos doit être fourni')
+
     def clean(self):
         cleaned_data = super().clean()
         self._validate_contact_information(cleaned_data)
         self._validate_floors(cleaned_data)
         self._validate_share_fields(cleaned_data)
+        #tetaneutral.net: comment with link to photos required for new connection request
+        self._validate_comment(cleaned_data)
         return cleaned_data
 
     def privacy_fields(self):
@@ -94,3 +103,15 @@ class PublicContribForm(forms.ModelForm):
 
         for f in ['latitude', 'longitude']:
             self.fields[f].error_messages['required'] = "Veuillez déplacer le curseur à l'endroit où vous voulez partager/accéder au service"
+
+
+class ManageActionForm(forms.Form):
+    ACTION_DELETE = 'delete'
+    ACTION_RENEW = 'renew'
+
+    action = forms.ChoiceField(
+        widget=forms.HiddenInput(),
+        choices=(
+            (ACTION_DELETE, ACTION_DELETE),
+            (ACTION_RENEW, ACTION_RENEW),
+    ))

+ 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],
+        )

+ 20 - 0
wifiwithme/apps/contribmap/migrations/0015_contrib_expiration_date.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.13 on 2017-07-31 14:51
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0014_auto_20160515_1050'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='contrib',
+            name='expiration_date',
+            field=models.DateTimeField(null=True),
+        ),
+    ]

+ 24 - 0
wifiwithme/apps/contribmap/migrations/0016_set_expiration_date.py

@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.13 on 2017-08-01 15:51
+from __future__ import unicode_literals
+
+from django.db import migrations
+from contribmap.utils import add_one_year
+
+
+
+def add_missing_expiration_date(apps, schema_editor):
+    Contrib = apps.get_model('contribmap', 'contrib')
+    for contrib in Contrib.objects.filter(expiration_date=None):
+        contrib.expiration_date = add_one_year(contrib.date)
+        contrib.save()
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0015_contrib_expiration_date'),
+    ]
+
+    operations = [
+        migrations.RunPython(add_missing_expiration_date),
+    ]

+ 20 - 0
wifiwithme/apps/contribmap/migrations/0017_auto_20170801_1610.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.13 on 2017-08-01 16:10
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0016_set_expiration_date'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contrib',
+            name='expiration_date',
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+    ]

+ 25 - 0
wifiwithme/apps/contribmap/migrations/0018_auto_20171016_2200.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.13 on 2017-10-16 22:00
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0017_auto_20170801_1610'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contrib',
+            name='date',
+            field=models.DateTimeField(auto_now_add=True, verbose_name="Date d'enregistrement"),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='expiration_date',
+            field=models.DateTimeField(blank=True, null=True, verbose_name="Date d'expiration"),
+        ),
+    ]

+ 81 - 2
wifiwithme/apps/contribmap/models.py

@@ -1,11 +1,33 @@
 # -*- 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
 
 from .fields import CommaSeparatedCharField
-from .utils import 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):
@@ -65,7 +87,12 @@ class Contrib(models.Model):
     privacy_comment = models.BooleanField(
         'commentaire public',
         default=False)
-    date = models.DateTimeField(auto_now_add=True)
+    date = models.DateTimeField(
+        "date d'enregistrement",
+        auto_now_add=True)
+    expiration_date = models.DateTimeField(
+        "date d'expiration",
+        null=True, blank=True)
 
     STATUS_TOSTUDY = 'TOSTUDY'
     STATUS_TOCONNECT = 'TOCONNECT'
@@ -103,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)
 
@@ -122,9 +151,34 @@ class Contrib(models.Model):
         angles.sort(key=lambda i: i[0])  # sort by x
         return merge_intervals(angles)
 
+    def get_postponed_expiration_date(self, from_date):
+        """ Computes the new expiration date
+
+        :param from_date: reference datetime frow where we add our extra delay.
+        """
+        return add_one_year(from_date)
+
+    def clean(self):
+        # usefull only for data imported from bottle version
+        if not self.date:
+            self.date = timezone.now()
+        if not self.expiration_date:
+            self.expiration_date = self.get_postponed_expiration_date(
+                self.date)
+
+    def save(self, *args, **kwargs):
+        if not self.pk:  # New instance
+            self.date = timezone.now()
+            self.expiration_date = self.get_postponed_expiration_date(
+                self.date)
+        super().save(*args, **kwargs)
+
     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
 
@@ -146,3 +200,28 @@ class Contrib(models.Model):
 
         else:
             return None
+
+    def get_absolute_url(self, request=None, base_url=None):
+        """ Get absolute url
+
+        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):
+        return '{}{}?token={}'.format(
+            settings.SITE_URL.strip('/'),
+            reverse('manage_contrib', kwargs={'pk': self.pk}),
+            token)

+ 7 - 0
wifiwithme/apps/contribmap/templates/base.html

@@ -91,6 +91,13 @@
       </div>
     </div>
   </nav>
+{% if messages %}
+<header class="messages">
+  {% for message in messages %}
+    <div class="alert {% if message.tags %} alert-{{ message.tags }}"{% endif %}>{{ message }}</div>
+    {% endfor %}
+</header>
+{% endif %}
   <section role="main" class="container">
     {% block content %}{% endblock %}
   </section>

+ 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 %}

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

@@ -0,0 +1 @@
+[wifi-with-me] votre demande, {{ contrib.name }}

+ 13 - 0
wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_author_notice.txt

@@ -0,0 +1,13 @@
+Chèr·e {{ contrib.name }},
+
+Votre demande a bien été enregistrée. Elle est en ligne publiquement à l'adresse : <{{ permalink }}>.
+
+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 votre demande grâce à ce lien privé à conserver :
+
+<{{ management_link }}>
+
+Bien à vous,
+
+Les bénévoles de {{ isp.NAME }}

wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_notice.subject → wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_moderator_notice.subject


+ 1 - 1
wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_notice.txt

@@ -1,6 +1,6 @@
 Nouvelle demande de la part de {{ contrib.name }}
 
-À retrouver sur {{ site_url }}
+À retrouver sur {{ permalink }}
 
 Bien à toi,
 

+ 66 - 0
wifiwithme/apps/contribmap/templates/contribmap/manage_contrib.html

@@ -0,0 +1,66 @@
+{% extends "base.html" %}
+
+{% load staticfiles %}
+
+{% block content %}
+
+
+<style>
+.jumbotron h2 {
+  margin-top: 0px;
+}
+
+section.jumbotron {
+  margin-bottom: 15px;
+}
+
+#map {
+  margin-left: -15px;
+  margin-right: -15px;
+  margin-bottom: 15px;
+//  margin-top: 30px;
+}
+</style>
+<script src="{% static 'minimap.js' %}" type="text/javascript"></script>
+<script src="{% static 'confirmation.js' %}" type="text/javascript"></script>
+<h1>Gérer ma demande</h1>
+
+<div id="map" data-lat="{{ contrib.latitude|stringformat:"f" }}" data-lon="{{ contrib.longitude|stringformat:"f" }}"></div>
+
+<section class="jumbotron">
+
+<h2>Prolonger ma demande</h2>
+
+<p>
+Sans intervention de votre part, votre demande, ainsi que les informations
+personelles associées seront supprimés de nos serveurs le
+<strong>{{ contrib.expiration_date|date:"j F o" }}</strong> (au bout d'un an), conformément à <a
+href="#">notre déclaration CNIL</a>, il vous faut donc la prolonger si vous
+souhaitez que nous la conservions.
+</p>
+
+<form method="post" action="">
+{% csrf_token %}
+{{ renew_form.as_p }}
+<button type="submit" class="btn btn-primary btn-lg"><span
+class="glyphicon glyphicon-repeat"></span> Maintenir ma demande jusqu'au {{ wanabe_expiration_date|date:"j F o" }}</button>
+</form>
+</section>
+
+<div class="jumbotron"">
+<h2>Effacer ma demande</h2>
+<p>
+    Si vous supprimez votre demande, elle sera retirée de nos serveurs
+immédiatement, ainsi que les informations personelles associées.
+</p>
+<form method="post" action="">
+{% csrf_token %}
+{{ delete_form.as_p }}
+<button type="submit" class="btn btn-danger btn-lg confirmation-protected">
+    <span class="glyphicon glyphicon-trash"></span>
+    Effacer ma demande
+</button>
+</form>
+
+</div>
+{% endblock %}

+ 13 - 0
wifiwithme/apps/contribmap/templates/contribmap/thanks.html

@@ -9,6 +9,19 @@
 <p>
 Votre contribution a bien été enregistrée.
 </p>
+
+<p>
+Pour pouvoir la modifier ou la supprimer ultérieurement, veillez à bien conserver le lien suivant : 
+
+<a href="{{ management_link }}">{{ management_link }}</a>
+</p>
+
+{% if contrib.email %}
+<p>
+    Ce lien vous a également été envoyé par email.
+</p>
+{% endif %}
+
 <p>
 Si vous voulez <strong>rester en
 contact</strong> avec {{ isp.NAME }}, nous rencontrer ou vous tenir informé, ça

+ 2 - 2
wifiwithme/apps/contribmap/templates/contribmap/wifi-form.html

@@ -62,8 +62,8 @@
 
     <h3>Type de connexion</h3>
     <div id="id_access_type">
-      {% for i in form.access_type %}
-      {% if i.choice_value %}<p class="radio">{{ i }}</p>{% endif %}
+      {% for i in form.access_type.subwidgets %}
+        {% if i.data.value %}<p class="radio">{{ i }}</p>{% endif %}
       {% endfor %}
     </div>
 

+ 262 - 49
wifiwithme/apps/contribmap/tests.py

@@ -1,11 +1,18 @@
+import datetime
 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
+from freezegun import freeze_time
+import pytz
 
 from contribmap.models import Contrib
 from contribmap.forms import PublicContribForm
+from contribmap.tokens import ContribTokenManager, URLTokenManager
 
 
 class APITestClient(Client):
@@ -83,7 +90,71 @@ 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 = {
+            'roof': True,
+            'privacy_place_details': True,
+            'privacy_coordinates': True,
+            'phone': '0202020202',
+            'orientations': ('N', 'NO', 'O', 'SO', 'S', 'SE', 'E', 'NE'),
+            'orientation': 'all',
+            'name': 'JohnCleese',
+            'longitude': -1.553621,
+            'latitude': 47.218371,
+            'floor_total': '2',
+            'floor': 1,
+            'email': 'coucou@example.com',
+            'contrib_type': 'connect',
+            'connect_local': 'on',
+        }
+        post_data.update(kwargs)
+        return post_data
+
     def test_public_json(self):
         response = self.client.json_get('/map/public.json')
         self.assertEqual(response.status_code, 200)
@@ -115,37 +186,107 @@ class TestViews(APITestCase):
         self.assertEqual(response.status_code, 200)
 
     @override_settings(NOTIFICATION_EMAILS=['foo@example.com'])
-    def test_add_contrib_sends_email(self):
-        response = self.client.post('/map/contribute', {
-            'roof': True,
-            'privacy_place_details': True,
-            'privacy_coordinates': True,
-            'phone': '0202020202',
-            'orientations': 'N',
-            'orientations': 'NO',
-            'orientations': 'O',
-            'orientations': 'SO',
-            'orientations': 'S',
-            'orientations': 'SE',
-            'orientations': 'E',
-            'orientations': 'NE',
-            'orientation': 'all',
-            'name': 'JohnCleese',
-            'longitude': -1.553621,
-            'latitude': 47.218371,
-            'floor_total': '2',
-            'floor': 1,
-            'email': 'coucou@example.com',
-            'contrib_type': 'connect',
-            'connect_local': 'on',
-        })
+    def test_add_contrib_sends_moderator_email(self):
+        post_data = self.mk_contrib_post_data({'name': 'JohnCleese'})
+        del post_data['email']
+
+        response = self.client.post('/map/contribute', post_data)
         self.assertEqual(response.status_code, 302)
 
         self.assertEqual(len(mail.outbox), 1)
         self.assertIn('JohnCleese', mail.outbox[0].subject)
         self.assertIn('JohnCleese', mail.outbox[0].body)
+        self.assertEqual(mail.outbox[0].recipients(), ['foo@example.com'])
+
+    def test_add_contrib_sends_no_author_email(self):
+        # Send no email if author did not mentioned an email
+        post_data = self.mk_contrib_post_data()
+        del post_data['email']
+
+        response = self.client.post('/map/contribute', post_data)
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_add_contrib_sends_author_email(self):
+        # Send no email if author did not mentioned an email
+        response = self.client.post(
+            '/map/contribute',
+            self.mk_contrib_post_data(email='author@example.com'))
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(len(mail.outbox), 1)
+
+
+class TestManageView(APITestCase):
+    def setUp(self):
+        self.contrib = Contrib.objects.create(
+            name='John',
+            phone='010101010101',
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            privacy_coordinates=True,
+            latitude=0.5,
+            longitude=0.5,
+        )
+        self.token = ContribTokenManager().mk_token(self.contrib)
+
+    def test_manage_with_token(self):
+        # No token
+        response = self.client.get('/map/manage/{}'.format(self.contrib.pk))
+        self.assertEqual(response.status_code, 403)
+
+        # Garbage token
+        response = self.client.get(
+            '/map/manage/{}?token=burp'.format(self.contrib.pk))
+        self.assertEqual(response.status_code, 403)
+
+        # Valid token, but for another contrib
+        contrib2 = Contrib.objects.create(
+            name='John2',
+            phone='010101010101',
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            privacy_coordinates=True,
+            latitude=0.5,
+            longitude=0.5,
+        )
+        token2 = ContribTokenManager().mk_token(contrib2)
+
+        response = self.client.get('/map/manage/{}?token={}'.format(
+            self.contrib.pk, token2))
+        self.assertEqual(response.status_code, 403)
+
+        # Normal legitimate access case
+        response = self.client.get(
+            '/map/manage/{}?token={}'.format(self.contrib.pk, self.token))
+        self.assertEqual(response.status_code, 200)
+
+        # Deleted contrib
+        Contrib.objects.all().delete()
+        response = self.client.get(
+            '/map/manage/{}?token={}'.format(self.contrib.pk, self.token))
+        self.assertEqual(response.status_code, 404)
+
+    def test_delete(self):
+        response = self.client.post(
+            '/map/manage/{}?token={}'.format(self.contrib.pk, self.token),
+            {'action': 'delete'})
+        self.assertEqual(response.status_code, 302)
+        self.assertFalse(Contrib.objects.filter(pk=self.contrib.pk).exists())
+
+    def test_renew(self):
+        self.contrib.date = datetime.datetime(2009, 10, 10, tzinfo=pytz.utc)
+        self.contrib.expiration_date = datetime.datetime(2010, 10, 10, tzinfo=pytz.utc)
+        self.contrib.save()
+
+        with freeze_time('12-12-2100', tz_offset=0):
+            response = self.client.post(
+                '/map/manage/{}?token={}'.format(self.contrib.pk, self.token),
+                {'action': 'renew'})
+        self.assertEqual(response.status_code, 200)
+        self.contrib = Contrib.objects.get(pk=self.contrib.pk)  # refresh
+        self.assertEqual(
+            self.contrib.expiration_date.date(),
+            datetime.date(2101, 12, 12))
+
 
-<<<<<<< HEAD
 class TestForms(TestCase):
     valid_data = {
         'roof': True,
@@ -203,30 +344,9 @@ class TestForms(TestCase):
 
     @override_settings(NOTIFICATION_EMAILS=['foo@example.com'])
     def test_add_contrib_like_a_robot(self):
-        response = self.client.post('/map/contribute', {
-            'roof': True,
-            'human_field': 'should not have no value',
-            'privacy_place_details': True,
-            'privacy_coordinates': True,
-            'phone': '0202020202',
-            'orientations': 'N',
-            'orientations': 'NO',
-            'orientations': 'O',
-            'orientations': 'SO',
-            'orientations': 'S',
-            'orientations': 'SE',
-            'orientations': 'E',
-            'orientations': 'NE',
-            'orientation': 'all',
-            'name': 'JohnCleese',
-            'longitude': -1.553621,
-            'latitude': 47.218371,
-            'floor_total': '2',
-            'floor': 1,
-            'email': 'coucou@example.com',
-            'contrib_type': 'connect',
-            'connect_local': 'on',
-        })
+        robot_data = self.valid_data.copy()
+        robot_data['human_field'] = 'should contain no value'
+        response = self.client.post('/map/contribute', robot_data)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(len(mail.outbox), 0)
 
@@ -234,7 +354,100 @@ class TestForms(TestCase):
 class TestDataImport(TestCase):
     fixtures = ['bottle_data.yaml']
 
+    @classmethod
+    def setUpClass(cls, *args, **kwargs):
+        # Silence the warnings about naive datetimes contained in the yaml.
+        with warnings.catch_warnings():  # Scope warn catch to this block
+            warnings.simplefilter('ignore', RuntimeWarning)
+            return super().setUpClass(*args, **kwargs)
+
     def test_re_save(self):
         for contrib in Contrib.objects.all():
             contrib.full_clean()
             contrib.save()
+
+
+class URLTokenManagerTests(TestCase):
+    def test_sign_unsign_ok(self):
+        input_data = {'foo': 12}
+        at = URLTokenManager().sign(input_data)
+        output_data = URLTokenManager().unsign(at)
+        self.assertEqual(input_data, output_data)
+
+    def test_sign_unsign_wrong_sig(self):
+        with self.assertRaises(BadSignature):
+            URLTokenManager().unsign(
+                b'eyJmb28iOiAxfTpvUFZ1Q3FsSldtQ2htMXJBMmx5VFV0ZWxDLWM')
+
+
+class ContribTokenManagerTests(TestCase):
+    def test_sign_unsign_ok(self):
+        Contrib.objects.create(
+            name='John2',
+            phone='010101020101',
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            privacy_coordinates=True,
+            latitude=0.1,
+            longitude=0.12,
+        )
+
+        contrib = Contrib.objects.all().first()
+
+        manager = ContribTokenManager()
+        token = manager.mk_token(contrib)
+
+        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)

+ 81 - 0
wifiwithme/apps/contribmap/tokens.py

@@ -0,0 +1,81 @@
+import json
+
+from django.core.signing import TimestampSigner, BadSignature
+from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
+from django.utils.encoding import DjangoUnicodeDecodeError
+
+from .models import Contrib
+
+
+class URLTokenManager(TimestampSigner):
+    """ Handle signed json data as URL-safe strings
+
+    This class has two responsibilities:
+    - sign/unsign
+    - pack/unpack JSON to base64
+    """
+
+    def sign(self, payload, *args, **kwargs):
+        """
+        :param payload: the data to be embeded into the token
+        :type data: dict
+        """
+        return urlsafe_base64_encode(
+            super().sign(
+                json.dumps(payload), *args, **kwargs
+            ).encode('utf-8')).decode('ascii')
+
+    def unsign(self, encoded_payload, *args, **kwargs):
+        decoded_payload = urlsafe_base64_decode(encoded_payload)
+        unsigned = super().unsign(decoded_payload)
+        return json.loads(unsigned)
+
+
+class TokenError(Exception):
+    pass
+
+
+class ContribTokenManager:
+    """Produce and use signed URL tokens for account-less contrib management
+    """
+    SCOPE = 'contrib/manage'
+
+    def __init__(self):
+        self.manager = URLTokenManager()
+
+    def mk_token(self, contrib):
+        """ Generate a signed contrib management token
+
+        Valid for a given contrib, and for a limited time.
+
+        :type contrib: Contrib
+        :rtype str:
+        """
+        return self.manager.sign({'scope': self.SCOPE, 'pk': contrib.pk})
+
+    def get_instance_if_allowed(self, encoded_token, pk=None):
+        """Return a contrib if the token grants authz for that Contrib pk
+
+        Raise a TokenError if not authorized.
+
+        :param pk: the contrib pk (optional, if you want to check that the
+          instance is the right one)
+        :param encoded_token: the encoded token, from ``mk_token()``:
+        :return: a Contrib instance or None, if the object does not exist
+        :rtype Contrib:
+        """
+        try:
+            data = self.manager.unsign(encoded_token)
+        except BadSignature:
+            raise TokenError('Invalid token signature')
+
+        except (DjangoUnicodeDecodeError, UnicodeDecodeError):
+            raise TokenError('This is not a well-formed token')
+
+        if (pk is not None) and (data['pk'] != pk):
+            raise TokenError('Token is not valid for id {}'.format(pk))
+        else:
+            try:
+                return Contrib.objects.get(pk=data['pk'])
+            except Contrib.DoesNotExist:
+                return None

+ 5 - 2
wifiwithme/apps/contribmap/urls.py

@@ -1,12 +1,15 @@
 from django.conf.urls import url
 
-from .views import PublicJSON, PrivateJSON, display_map, legal, add_contrib, thanks
+from .views import (
+    PublicJSON, PrivateJSON,
+    add_contrib, manage_contrib, display_map, legal,  thanks)
 
 urlpatterns = [
     url(r'^$', display_map, name='display_map'),
     url(r'^legal$', legal, name='legal'),
-    url(r'^contribute/thanks', thanks, name='thanks'),
+    url(r'^contribute/thanks/(?P<token>\w+)', thanks, name='thanks'),
     url(r'^contribute', add_contrib, name='add_contrib'),
+    url(r'^manage/(?P<pk>\d+)', manage_contrib, name='manage_contrib'),
     url(r'^public.json$', PublicJSON.as_view(), name='public_json'),
     url(r'^private.json$', PrivateJSON.as_view(), name='private_json'),
 ]

+ 17 - 0
wifiwithme/apps/contribmap/utils.py

@@ -1,3 +1,6 @@
+import datetime
+from django.utils import timezone
+
 ANGLES = {
     'N': (-23, 22),
     'NO': (292, 337),
@@ -35,3 +38,17 @@ def merge_intervals(l, wrap=360):
         result[-1][1] = max(result[-1][1], first[1] + wrap)
         result.pop(0)
     return result
+
+
+def add_one_year(date):
+    new_day, new_month, new_year = date.day, date.month, date.year + 1
+
+    try:
+        new_date = timezone.make_aware(
+            datetime.datetime(new_year, new_month, new_day))
+    except ValueError:  # Hello 29/3
+        new_day -= 1
+        new_date = timezone.make_aware(
+            datetime.datetime(new_year, new_month, new_day))
+
+    return new_date

+ 117 - 18
wifiwithme/apps/contribmap/views.py

@@ -1,15 +1,23 @@
-from django.conf import settings
+import json
+import datetime
 
+from django.conf import settings
+from django.contrib import messages
 from django.core.urlresolvers import reverse
 from django.core.mail import send_mail
-from django.http import JsonResponse, HttpResponseForbidden
-from django.shortcuts import render, redirect
+from django.core.signing import BadSignature
+from django.http import (
+    JsonResponse, HttpResponseBadRequest, HttpResponseForbidden,
+    HttpResponseNotFound)
+from django.shortcuts import render, redirect, get_object_or_404
 from django.template.loader import get_template
 from django.views.generic import View
+import pytz
 
-from .forms import PublicContribForm
+from .forms import ManageActionForm, PublicContribForm
 from .models import Contrib
 from .decorators import prevent_robots
+from .tokens import ContribTokenManager, URLTokenManager, TokenError
 
 
 @prevent_robots()
@@ -21,31 +29,114 @@ def add_contrib(request):
 
         if form.is_valid():
             contrib = form.save()
+            mgmt_token = ContribTokenManager().mk_token(contrib)
+
+            context = {
+                'contrib': contrib,
+                'management_link': contrib.make_management_url(mgmt_token),
+                'permalink': contrib.get_absolute_url(request),
+                'isp': settings.ISP,
+            }
 
-            # Send notification email
+            # Send notification email to site administrator
             if len(settings.NOTIFICATION_EMAILS) > 0:
-                context = {
-                    'site_url': settings.SITE_URL,
-                    'contrib': contrib,
-                }
-                subject = get_template(
-                    'contribmap/mails/new_contrib_notice.subject')
-                body = get_template(
-                    'contribmap/mails/new_contrib_notice.txt')
+                admin_subject = get_template(
+                    'contribmap/mails/new_contrib_moderator_notice.subject')
+                admin_body = get_template(
+                    'contribmap/mails/new_contrib_moderator_notice.txt')
                 send_mail(
-                    subject.render(context),
-                    body.render(context),
+                    admin_subject.render(context),
+                    admin_body.render(context),
                     settings.DEFAULT_FROM_EMAIL,
                     settings.NOTIFICATION_EMAILS,
                 )
 
-            return redirect(reverse('thanks'))
+            # Notification email to the author
+            if contrib.email:
+                author_subject = get_template(
+                    'contribmap/mails/new_contrib_author_notice.subject')
+                author_body = get_template(
+                    'contribmap/mails/new_contrib_author_notice.txt')
+
+                send_mail(
+                    author_subject.render(context),
+                    author_body.render(context),
+                    settings.DEFAULT_FROM_EMAIL,
+                    [contrib.email],
+                )
+
+            return redirect(reverse('thanks', kwargs={
+                'token': mgmt_token,
+            }))
+
     return render(request, 'contribmap/wifi-form.html', {
         'form': form,
         'isp':settings.ISP,
     })
 
 
+def manage_contrib(request, pk):
+    """ Contribution management by the user itself
+
+    Auth is done by signed token
+    """
+    try:
+        token = request.GET['token']
+    except KeyError:
+        return HttpResponseForbidden(
+            'Missing authorization token')
+    pk = int(pk)
+
+    try:
+        contrib = ContribTokenManager().get_instance_if_allowed(token, pk)
+
+    except TokenError:
+        return HttpResponseForbidden(
+            'Bad signature, or expired token')
+
+    else:
+        if not contrib:
+            return HttpResponseNotFound("Inexistant Contrib")
+
+        wanabe_expiration_date = contrib.get_postponed_expiration_date(
+            datetime.datetime.now(pytz.utc))
+        if request.POST:
+            action_form = ManageActionForm(request.POST)
+            if not action_form.is_valid():
+                return HttpResponseBadRequest('Action invalide')
+
+            action = action_form.cleaned_data['action']
+            if action == action_form.ACTION_DELETE:
+                contrib.delete()
+                messages.add_message(
+                    request, messages.INFO,
+                    'Votre demande a bien été supprimée.')
+                return redirect(reverse('display_map'))
+
+            elif action == action_form.ACTION_RENEW:
+                contrib.expiration_date = wanabe_expiration_date
+                contrib.save()
+                messages.add_message(
+                    request, messages.INFO,
+                    "Votre demande a été prolongée jusqu'au {:%d/%m/%Y}".format(
+                        contrib.expiration_date))
+            else:
+                return HttpResponseBadRequest('Action invalide')
+
+        return render(request, 'contribmap/manage_contrib.html', {
+            'contrib': contrib,
+            'wanabe_expiration_date': wanabe_expiration_date,
+            'delete_form': ManageActionForm({
+                'action': ManageActionForm.ACTION_DELETE
+            }),
+            'renew_form': ManageActionForm({
+                'action': ManageActionForm.ACTION_RENEW
+            }),
+            'messages': messages.api.get_messages(request),
+            'isp': settings.ISP,
+        })
+
+
 def display_map(request):
     private_mode = request.user.is_authenticated()
     if private_mode:
@@ -60,9 +151,17 @@ def display_map(request):
     })
 
 
-def thanks(request):
+def thanks(request, token):
+    try:
+        contrib = ContribTokenManager().get_instance_if_allowed(token)
+    except TokenError:
+        return HttpResponseForbidden(
+            'Bad signature, or expired token')
+
     return render(request, 'contribmap/thanks.html', {
-        'isp':settings.ISP,
+        'isp': settings.ISP,
+        'management_link': contrib.make_management_url(token),
+        'contrib': contrib,
     })
 
 def legal(request):

+ 1 - 1
wifiwithme/core/templates/registration/login.html

@@ -5,7 +5,7 @@
 {% if form.errors %}
 <p class="alert alert-danger">Mauvais utilisateur / mot de passe, essaye encore..</p>
 {% endif %}
-<form method="post" action="{% url 'django.contrib.auth.views.login' %}">
+<form method="post" action="{% url 'login' %}">
 {% csrf_token %}
   <div class="form-group">
     {{ form.username.label_tag }}

+ 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'

+ 2 - 0
wifiwithme/settings/dev.py

@@ -14,5 +14,7 @@ try:
 except ImportError:
     pass
 
+# Send emails to console
+NOTIFICATION_EMAILS = ['moderator@example.com']
 EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
 STATIC_URL = '/{}assets/'.format(URL_PREFIX)

+ 14 - 0
wifiwithme/static/confirmation.js

@@ -0,0 +1,14 @@
+$(document).ready(function() {
+  /* If JS is enabled, add a confirmation box.
+   * If not, delete without confirmation.
+   */
+  $('.confirmation-protected')
+  .attr('type', 'button')
+  .click(function(evt) {
+    var confirmed = confirm(
+      'Êtes-vous certain·e de vouloir supprimer votre contribution ?')
+    if (confirmed) {
+      $(evt.target).parent('form').submit();
+    }
+  });
+});

+ 1 - 1
wifiwithme/static/main.css

@@ -1,4 +1,3 @@
-
 .main-header h1 a,
 .main-header h1 a:visited,
 .main-header h1 a:link {
@@ -21,6 +20,7 @@ body {
   position: relative;
   min-height: 1500px;
   padding-bottom: 200px;
+  font-size: auto;
 }
 
 

+ 14 - 0
wifiwithme/static/minimap.js

@@ -0,0 +1,14 @@
+$( document ).ready(function() {
+    // Create map
+    var lat = parseFloat($('#map').data("lat"));
+    var lon = parseFloat($('#map').data("lon"));
+
+    var map = L.map('map', {scrollWheelZoom: false}).setView([lat, lon], 15);
+;
+    L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+        attribution: 'Map data &copy; <a href="//openstreetmap.org">OpenStreetMap</a> contributors, <a href="//creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="//mapbox.com">Mapbox</a>',
+        maxZoom: 18
+    }).addTo(map);
+
+  var marker = L.marker([lat, lon]).addTo(map);
+});