#50 Permettre au contributeur de prolonger/supprimer sa contribution

Fusionné
jocelyn a fusionné 13 commits à partir de FFDN/jd-contribution-manage-link vers FFDN/master il y a 7 ans

+ 1 - 0
requirements/base.txt

@@ -1,4 +1,5 @@
 Django>=1.9.3,<1.10
 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

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

@@ -16,9 +16,14 @@ class ContribAdmin(admin.ModelAdmin):
     search_fields = ["name", "email", "phone"]
     list_display = ("name", "date", "phone", "email")
 
+    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': [

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

@@ -94,3 +94,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),
+    ))

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

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

@@ -2,10 +2,12 @@
 
 from __future__ import unicode_literals
 
+from django.core.urlresolvers import reverse
 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 Contrib(models.Model):
@@ -65,7 +67,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'
@@ -122,6 +129,28 @@ 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
 
@@ -146,3 +175,23 @@ class Contrib(models.Model):
 
         else:
             return None
+
+    def get_absolute_url(self, request=None):
+        """ Get absolute url
+
+        :type param: request
+        :param: if mentioned, will be used to provide a full URL (starting with
+        "http://" or "https://")
+        """
+        url = '{}#{}'.format(
+            reverse('display_map'), self.pk)
+        if request:
+            return request.build_absolute_uri(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))

+ 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/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 ta demande grace à ce lien privé à conserver :
+
+<{{ management_link }}>
+
+Bien à toi,
+
+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


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


+ 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

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

@@ -1,11 +1,17 @@
+import datetime
 import json
+import warnings
 
 from django.core import mail
+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):
@@ -84,6 +90,26 @@ class TestContribPrivacy(TestCase):
 
 
 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 +141,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 +299,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 +309,48 @@ 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)

+ 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

+ 121 - 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,30 +29,117 @@ def add_contrib(request):
 
         if form.is_valid():
             contrib = form.save()
-            # Send notification email
+            mgmt_token = ContribTokenManager().mk_token(contrib)
+
+            context = {
+                'site_url': (settings.SITE_URL + reverse('display_map')
+                             + '#{}'.format(contrib.id)),
+                'contrib': contrib,
+                'management_link': contrib.make_management_url(
+                    mgmt_token, request),
+                'permalink': contrib.get_absolute_url(request),
+                'isp':settings.ISP,
+            }
+
+            # Send notification email to site administrator
             if len(settings.NOTIFICATION_EMAILS) > 0:
-                context = {
-                    'contrib_url': settings.SITE_URL + reverse('display_map') + '#{}'.format(contrib.id),
-                    '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:
@@ -59,9 +154,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, request),
+        'contrib': contrib,
     })
 
 def legal(request):

+ 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);
+});