Commit c323d009 authored by jocelyn's avatar jocelyn Committed by Team adminsys FFDN
Browse files

Merge branch 'jd-contribution-manage-link' of FFDN/wifi-with-me into master

parents 15a4581f 3e3c9145
Django>=1.9.3,<1.10
PyYAML>=3.11,<4.0
django-request-token>=0.6,<0.7
pytz
sqlparse
-r base.txt
django-debug-toolbar
freezegun
......@@ -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': [
......
......@@ -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),
))
# -*- 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),
),
]
# -*- 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),
]
# -*- 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),
),
]
# -*- 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"),
),
]
......@@ -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))
......@@ -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>
......
[wifi-with-me] votre demande, {{ contrib.name }}
\ No newline at end of file
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 }}
{% 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 %}
......@@ -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
......
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)