From 6059ca067b96f3eb2071f022a39ef2d7d1cc5cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 15 Jan 2018 05:26:33 +0100 Subject: [PATCH 01/22] Speed up tests ~20% less using MD5 and force_login in kfet testcase. ~77% less by disabling the debug tollbar. --- cof/settings/common.py | 3 +++ cof/settings/dev.py | 22 ++++++++++++++-------- kfet/tests/testcases.py | 8 +------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/cof/settings/common.py b/cof/settings/common.py index 48242fc3..708e90a0 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -7,6 +7,7 @@ the local development server should be here. """ import os +import sys try: from . import secret @@ -53,6 +54,8 @@ BASE_DIR = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) +TESTING = sys.argv[1] == 'test' + # Application definition INSTALLED_APPS = [ diff --git a/cof/settings/dev.py b/cof/settings/dev.py index 6e1f6b11..114f37da 100644 --- a/cof/settings/dev.py +++ b/cof/settings/dev.py @@ -4,13 +4,18 @@ The settings that are not listed here are imported from .common """ from .common import * # NOQA -from .common import INSTALLED_APPS, MIDDLEWARE +from .common import INSTALLED_APPS, MIDDLEWARE, TESTING EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEBUG = True +if TESTING: + PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', + ] + # --- # Apache static/media config @@ -36,12 +41,13 @@ def show_toolbar(request): """ return DEBUG -INSTALLED_APPS += ["debug_toolbar", "debug_panel"] +if not TESTING: + INSTALLED_APPS += ["debug_toolbar", "debug_panel"] -MIDDLEWARE = [ - "debug_panel.middleware.DebugPanelMiddleware" -] + MIDDLEWARE + MIDDLEWARE = [ + "debug_panel.middleware.DebugPanelMiddleware" + ] + MIDDLEWARE -DEBUG_TOOLBAR_CONFIG = { - 'SHOW_TOOLBAR_CALLBACK': show_toolbar, -} + DEBUG_TOOLBAR_CONFIG = { + 'SHOW_TOOLBAR_CALLBACK': show_toolbar, + } diff --git a/kfet/tests/testcases.py b/kfet/tests/testcases.py index 3ea428c3..aa2fb1b6 100644 --- a/kfet/tests/testcases.py +++ b/kfet/tests/testcases.py @@ -245,13 +245,7 @@ class ViewTestCaseMixin(TestCaseMixin): self.register_user(label, user) if self.auth_user: - # The wrapper is a sanity check. - self.assertTrue( - self.client.login( - username=self.auth_user, - password=self.auth_user, - ) - ) + self.client.force_login(self.users[self.auth_user]) def tearDown(self): del self.users_base From e23e1bdba6c6ddbc4e80e23b2099c7e6e99788f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 15 Jan 2018 16:52:46 +0100 Subject: [PATCH 02/22] kfet -- Add test to check the choices of checkouts in K-Psul Particularly, it adds a regression test for #184. --- kfet/tests/test_forms.py | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 kfet/tests/test_forms.py diff --git a/kfet/tests/test_forms.py b/kfet/tests/test_forms.py new file mode 100644 index 00000000..e946d39d --- /dev/null +++ b/kfet/tests/test_forms.py @@ -0,0 +1,48 @@ +import datetime +from unittest import mock + +from django.test import TestCase +from django.utils import timezone + +from kfet.forms import KPsulCheckoutForm +from kfet.models import Checkout + +from .utils import create_user + + +class KPsulCheckoutFormTests(TestCase): + + def setUp(self): + self.now = timezone.now() + + user = create_user() + + self.c1 = Checkout.objects.create( + name='C1', balance=10, + created_by=user.profile.account_kfet, + valid_from=self.now, + valid_to=self.now + datetime.timedelta(days=1), + ) + + self.form = KPsulCheckoutForm() + + def test_checkout(self): + checkout_f = self.form.fields['checkout'] + self.assertListEqual(list(checkout_f.choices), [ + ('', '---------'), + (self.c1.pk, 'C1'), + ]) + + @mock.patch('django.utils.timezone.now') + def test_checkout_valid(self, mock_now): + """ + Checkout are filtered using the current datetime. + Regression test for #184. + """ + self.now += datetime.timedelta(days=2) + mock_now.return_value = self.now + + form = KPsulCheckoutForm() + + checkout_f = form.fields['checkout'] + self.assertListEqual(list(checkout_f.choices), [('', '---------')]) From 525bb4d16dc7057fa746d5e66c9c8ebb336466b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 15 Jan 2018 16:56:38 +0100 Subject: [PATCH 03/22] kfet -- Fix available checkouts in K-Psul The checkout validity is checked using the current datetime (when requesting the kpsul page). --- kfet/forms.py | 16 ++++++++-------- kfet/models.py | 9 +++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/kfet/forms.py b/kfet/forms.py index 963e4254..26774b1c 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -296,17 +296,17 @@ class KPsulAccountForm(forms.ModelForm): class KPsulCheckoutForm(forms.Form): checkout = forms.ModelChoiceField( - queryset=( - Checkout.objects - .filter( - is_protected=False, - valid_from__lte=timezone.now(), - valid_to__gte=timezone.now(), - ) - ), + queryset=None, widget=forms.Select(attrs={'id': 'id_checkout_select'}), ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Create the queryset on form instanciation to use the current time. + self.fields['checkout'].queryset = ( + Checkout.objects.is_valid().filter(is_protected=False)) + class KPsulOperationForm(forms.ModelForm): article = forms.ModelChoiceField( diff --git a/kfet/models.py b/kfet/models.py index deee76eb..c9de48bc 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -341,6 +341,13 @@ class AccountNegative(models.Model): return self.start + kfet_config.overdraft_duration +class CheckoutQuerySet(models.QuerySet): + + def is_valid(self): + now = timezone.now() + return self.filter(valid_from__lte=now, valid_to__gte=now) + + class Checkout(models.Model): created_by = models.ForeignKey( Account, on_delete = models.PROTECT, @@ -353,6 +360,8 @@ class Checkout(models.Model): default = 0) is_protected = models.BooleanField(default = False) + objects = CheckoutQuerySet.as_manager() + def get_absolute_url(self): return reverse('kfet.checkout.read', kwargs={'pk': self.pk}) From 478f56d94b6db8a185bbd0dd821147c06a2bfafc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Tue, 16 Jan 2018 15:59:17 +0100 Subject: [PATCH 04/22] kfet -- Create initial statement on checkout save - Why? Because it should be the actual behavior. - To allow using arithmetic operations with values of DecimalField when object are not retrieved from DB, some strings are replaced by Decimal or int. If you wonder why it's not automatically done, see: https://code.djangoproject.com/ticket/27825 --- kfet/models.py | 16 ++++++++++++++++ kfet/tests/test_models.py | 37 ++++++++++++++++++++++++++++++++++++- kfet/tests/test_views.py | 19 ++++++++++++------- kfet/views.py | 10 +--------- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/kfet/models.py b/kfet/models.py index deee76eb..3f38cc44 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -362,6 +362,22 @@ class Checkout(models.Model): def __str__(self): return self.name + def save(self, *args, **kwargs): + created = self.pk is None + + ret = super().save(*args, **kwargs) + + if created: + self.statements.create( + amount_taken=0, + balance_old=self.balance, + balance_new=self.balance, + by=self.created_by, + ) + + return ret + + class CheckoutTransfer(models.Model): from_checkout = models.ForeignKey( Checkout, on_delete = models.PROTECT, diff --git a/kfet/tests/test_models.py b/kfet/tests/test_models.py index ea132acd..727cac4e 100644 --- a/kfet/tests/test_models.py +++ b/kfet/tests/test_models.py @@ -1,7 +1,12 @@ +import datetime + from django.contrib.auth import get_user_model from django.test import TestCase +from django.utils import timezone -from kfet.models import Account +from kfet.models import Account, Checkout + +from .utils import create_user User = get_user_model() @@ -23,3 +28,33 @@ class AccountTests(TestCase): with self.assertRaises(Account.DoesNotExist): Account.objects.get_by_password('bernard') + + +class CheckoutTests(TestCase): + + def setUp(self): + self.now = timezone.now() + + self.u = create_user() + self.u_acc = self.u.profile.account_kfet + + self.c = Checkout( + created_by=self.u_acc, + valid_from=self.now, + valid_to=self.now + datetime.timedelta(days=1), + ) + + def test_initial_statement(self): + """A statement is added with initial balance on creation.""" + self.c.balance = 10 + self.c.save() + + st = self.c.statements.get() + self.assertEqual(st.balance_new, 10) + self.assertEqual(st.amount_taken, 0) + self.assertEqual(st.amount_error, 0) + + # Saving again doesn't create a new statement. + self.c.save() + + self.assertEqual(self.c.statements.count(), 1) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index 41ed8b5c..40f895a1 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -746,12 +746,16 @@ class CheckoutReadViewTests(ViewTestCaseMixin, TestCase): def setUp(self): super().setUp() - self.checkout = Checkout.objects.create( - name='Checkout', - created_by=self.accounts['team'], - valid_from=self.now, - valid_to=self.now + timedelta(days=5), - ) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = self.now + + self.checkout = Checkout.objects.create( + name='Checkout', balance=Decimal('10'), + created_by=self.accounts['team'], + valid_from=self.now, + valid_to=self.now + timedelta(days=1), + ) def test_ok(self): r = self.client.get(self.url) @@ -794,7 +798,7 @@ class CheckoutUpdateViewTests(ViewTestCaseMixin, TestCase): name='Checkout', valid_from=self.now, valid_to=self.now + timedelta(days=5), - balance='3.14', + balance=Decimal('3.14'), is_protected=False, created_by=self.accounts['team'], ) @@ -864,6 +868,7 @@ class CheckoutStatementListViewTests(ViewTestCaseMixin, TestCase): self.assertQuerysetEqual( r.context['checkoutstatements'], map(repr, expected_statements), + ordered=False, ) diff --git a/kfet/views.py b/kfet/views.py index 1fe9ac22..2b69684d 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -528,15 +528,7 @@ class CheckoutCreate(SuccessMessageMixin, CreateView): # Creating form.instance.created_by = self.request.user.profile.account_kfet - checkout = form.save() - - # Création d'un relevé avec balance initiale - CheckoutStatement.objects.create( - checkout = checkout, - by = self.request.user.profile.account_kfet, - balance_old = checkout.balance, - balance_new = checkout.balance, - amount_taken = 0) + form.save() return super(CheckoutCreate, self).form_valid(form) From 57de31d59a82906b935237f533844397efa5e4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 17:57:43 +0100 Subject: [PATCH 05/22] cof -- Add tests for survey views --- gestioncof/tests/test_views.py | 178 +++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..b8860811 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,178 @@ +from django.contrib import messages +from django.contrib.messages import get_messages +from django.contrib.messages.storage.base import Message +from django.test import TestCase + +from gestioncof.models import Survey, SurveyAnswer +from gestioncof.tests.testcases import ViewTestCaseMixin + + +class SurveyViewTests(ViewTestCaseMixin, TestCase): + url_name = 'survey.details' + + http_methods = ['GET', 'POST'] + + auth_user = 'user' + auth_forbidden = [None] + + post_expected_message = Message(messages.SUCCESS, ( + "Votre réponse a bien été enregistrée ! Vous pouvez cependant la " + "modifier jusqu'à la fin du sondage." + )) + + @property + def url_kwargs(self): + return {'survey_id': self.s.pk} + + @property + def url_expected(self): + return '/survey/{}'.format(self.s.pk) + + def setUp(self): + super().setUp() + + self.s = Survey.objects.create(title='Title') + + self.q1 = self.s.questions.create(question='Question 1 ?') + self.q2 = self.s.questions.create( + question='Question 2 ?', + multi_answers=True, + ) + + self.qa1 = self.q1.answers.create(answer='Q1 - Answer 1') + self.qa2 = self.q1.answers.create(answer='Q1 - Answer 2') + self.qa3 = self.q2.answers.create(answer='Q2 - Answer 1') + self.qa4 = self.q2.answers.create(answer='Q2 - Answer 2') + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_new(self): + r = self.client.post(self.url, { + 'question_{}'.format(self.q1.pk): [str(self.qa1.pk)], + 'question_{}'.format(self.q2.pk): [ + str(self.qa3.pk), str(self.qa4.pk), + ], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + a = self.s.surveyanswer_set.get(user=self.users['user']) + self.assertQuerysetEqual( + a.answers.all(), map(repr, [self.qa1, self.qa3, self.qa4]), + ordered=False, + ) + + def test_post_edit(self): + a = self.s.surveyanswer_set.create(user=self.users['user']) + a.answers.add(self.qa1, self.qa1, self.qa4) + + r = self.client.post(self.url, { + 'question_{}'.format(self.q1.pk): [], + 'question_{}'.format(self.q2.pk): [str(self.qa3.pk)], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + a.refresh_from_db() + self.assertQuerysetEqual( + a.answers.all(), map(repr, [self.qa3]), + ordered=False, + ) + + def test_post_delete(self): + a = self.s.surveyanswer_set.create(user=self.users['user']) + a.answers.add(self.qa1, self.qa4) + + r = self.client.post(self.url, {'delete': '1'}) + + self.assertEqual(r.status_code, 200) + expected_message = Message( + messages.SUCCESS, "Votre réponse a bien été supprimée") + self.assertIn(expected_message, get_messages(r.wsgi_request)) + + with self.assertRaises(SurveyAnswer.DoesNotExist): + a.refresh_from_db() + + def test_forbidden_closed(self): + self.s.survey_open = False + self.s.save() + + r = self.client.get(self.url) + + self.assertNotEqual(r.status_code, 200) + + def test_forbidden_old(self): + self.s.old = True + self.s.save() + + r = self.client.get(self.url) + + self.assertNotEqual(r.status_code, 200) + + +class SurveyStatusViewTests(ViewTestCaseMixin, TestCase): + url_name = 'survey.details.status' + + http_methods = ['GET', 'POST'] + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + @property + def url_kwargs(self): + return {'survey_id': self.s.pk} + + @property + def url_expected(self): + return '/survey/{}/status'.format(self.s.pk) + + def setUp(self): + super().setUp() + + self.s = Survey.objects.create(title='Title') + + self.q1 = self.s.questions.create(question='Question 1 ?') + self.q2 = self.s.questions.create( + question='Question 2 ?', + multi_answers=True, + ) + + self.qa1 = self.q1.answers.create(answer='Q1 - Answer 1') + self.qa2 = self.q1.answers.create(answer='Q1 - Answer 2') + self.qa3 = self.q2.answers.create(answer='Q2 - Answer 1') + self.qa4 = self.q2.answers.create(answer='Q2 - Answer 2') + + self.a1 = self.s.surveyanswer_set.create(user=self.users['user']) + self.a1.answers.add(self.qa1) + self.a2 = self.s.surveyanswer_set.create(user=self.users['member']) + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def _get_qa_filter_name(self, qa): + return 'question_{}_answer_{}'.format(qa.survey_question.pk, qa.pk) + + def _test_filters(self, filters, expected): + r = self.client.post(self.url, { + self._get_qa_filter_name(qa): v for qa, v in filters + }) + + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['user_answers'], map(repr, expected), + ordered=False, + ) + + def test_filter_none(self): + self._test_filters([(self.qa1, 'none')], [self.a1, self.a2]) + + def test_filter_yes(self): + self._test_filters([(self.qa1, 'yes')], [self.a1]) + + def test_filter_no(self): + self._test_filters([(self.qa1, 'no')], [self.a2]) From 8675948d9e849acc028e79c78f7aed92b68101ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:01:36 +0100 Subject: [PATCH 06/22] cof -- Fix urls naming in survey templates --- gestioncof/templates/gestioncof/survey.html | 2 +- gestioncof/templates/survey_status.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gestioncof/templates/gestioncof/survey.html b/gestioncof/templates/gestioncof/survey.html index ccf447ef..9d4d67b3 100644 --- a/gestioncof/templates/gestioncof/survey.html +++ b/gestioncof/templates/gestioncof/survey.html @@ -8,7 +8,7 @@ {% if survey.details %}

{{ survey.details }}

{% endif %} -
+ {% csrf_token %} {{ form | bootstrap}} diff --git a/gestioncof/templates/survey_status.html b/gestioncof/templates/survey_status.html index 831a07bb..0e630c6e 100644 --- a/gestioncof/templates/survey_status.html +++ b/gestioncof/templates/survey_status.html @@ -11,7 +11,7 @@ {% endif %}

Filtres

{% include "tristate_js.html" %} - + {% csrf_token %} {{ form.as_p }} From ce734990776fe25f62be96b05ad589027bde0a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:15:57 +0100 Subject: [PATCH 07/22] Fix use of Widget.build_attrs in TriStateCheckbox Signature changed in Django 1.11. --- gestioncof/widgets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gestioncof/widgets.py b/gestioncof/widgets.py index 758fc4ad..cbc9cd93 100644 --- a/gestioncof/widgets.py +++ b/gestioncof/widgets.py @@ -20,6 +20,7 @@ class TriStateCheckbox(Widget): def render(self, name, value, attrs=None, choices=()): if value is None: value = 'none' - final_attrs = self.build_attrs(attrs, value=value) + attrs['value'] = value + final_attrs = self.build_attrs(self.attrs, attrs) output = ["" % flatatt(final_attrs)] return mark_safe('\n'.join(output)) From f5b280896fcaac00934e2341552ca4f3e21bda05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:36:03 +0100 Subject: [PATCH 08/22] cof -- Add tests for event views --- gestioncof/tests/test_views.py | 163 +++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..a3c423d3 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,163 @@ +from django.contrib import messages +from django.contrib.messages import get_messages +from django.contrib.messages.storage.base import Message +from django.test import TestCase + +from gestioncof.models import Event +from gestioncof.tests.testcases import ViewTestCaseMixin + + +class EventViewTests(ViewTestCaseMixin, TestCase): + url_name = 'event.details' + + http_methods = ['GET', 'POST'] + + auth_user = 'user' + auth_forbidden = [None] + + post_expected_message = Message(messages.SUCCESS, ( + "Votre inscription a bien été enregistrée ! Vous pouvez cependant la " + "modifier jusqu'à la fin des inscriptions." + )) + + @property + def url_kwargs(self): + return {'event_id': self.e.pk} + + @property + def url_expected(self): + return '/event/{}'.format(self.e.pk) + + def setUp(self): + super().setUp() + + self.e = Event.objects.create() + + self.ecf1 = self.e.commentfields.create(name='Comment Field 1') + self.ecf2 = self.e.commentfields.create( + name='Comment Field 2', fieldtype='char', + ) + + self.o1 = self.e.options.create(name='Option 1') + self.o2 = self.e.options.create(name='Option 2', multi_choices=True) + + self.oc1 = self.o1.choices.create(value='O1 - Choice 1') + self.oc2 = self.o1.choices.create(value='O1 - Choice 2') + self.oc3 = self.o2.choices.create(value='O2 - Choice 1') + self.oc4 = self.o2.choices.create(value='O2 - Choice 2') + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_new(self): + r = self.client.post(self.url, { + 'option_{}'.format(self.o1.pk): [str(self.oc1.pk)], + 'option_{}'.format(self.o2.pk): [ + str(self.oc3.pk), str(self.oc4.pk), + ], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + er = self.e.eventregistration_set.get(user=self.users['user']) + self.assertQuerysetEqual( + er.options.all(), map(repr, [self.oc1, self.oc3, self.oc4]), + ordered=False, + ) + # TODO: Make the view care about comments. + # self.assertQuerysetEqual( + # er.comments.all(), map(repr, []), + # ordered=False, + # ) + + def test_post_edit(self): + er = self.e.eventregistration_set.create(user=self.users['user']) + er.options.add(self.oc1, self.oc3, self.oc4) + er.comments.create( + commentfield=self.ecf1, content='Comment 1', + ) + + r = self.client.post(self.url, { + 'option_{}'.format(self.o1.pk): [], + 'option_{}'.format(self.o2.pk): [str(self.oc3.pk)], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + er.refresh_from_db() + self.assertQuerysetEqual( + er.options.all(), map(repr, [self.oc3]), + ordered=False, + ) + # TODO: Make the view care about comments. + # self.assertQuerysetEqual( + # er.comments.all(), map(repr, []), + # ordered=False, + # ) + + +class EventStatusViewTests(ViewTestCaseMixin, TestCase): + url_name = 'event.details.status' + + http_methods = ['GET', 'POST'] + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + @property + def url_kwargs(self): + return {'event_id': self.e.pk} + + @property + def url_expected(self): + return '/event/{}/status'.format(self.e.pk) + + def setUp(self): + super().setUp() + + self.e = Event.objects.create() + + self.cf1 = self.e.commentfields.create(name='Comment Field 1') + self.cf2 = self.e.commentfields.create( + name='Comment Field 2', fieldtype='char', + ) + + self.o1 = self.e.options.create(name='Option 1') + self.o2 = self.e.options.create(name='Option 2', multi_choices=True) + + self.oc1 = self.o1.choices.create(value='O1 - Choice 1') + self.oc2 = self.o1.choices.create(value='O1 - Choice 2') + self.oc3 = self.o2.choices.create(value='O2 - Choice 1') + self.oc4 = self.o2.choices.create(value='O2 - Choice 2') + + self.er1 = self.e.eventregistration_set.create(user=self.users['user']) + self.er1.options.add(self.oc1) + self.er2 = self.e.eventregistration_set.create( + user=self.users['member'], + ) + + def _get_oc_filter_name(self, oc): + return 'option_{}_choice_{}'.format(oc.event_option.pk, oc.pk) + + def _test_filters(self, filters, expected): + r = self.client.post(self.url, { + self._get_oc_filter_name(oc): v for oc, v in filters + }) + + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['user_choices'], map(repr, expected), + ordered=False, + ) + + def test_filter_none(self): + self._test_filters([(self.oc1, 'none')], [self.er1, self.er2]) + + def test_filter_yes(self): + self._test_filters([(self.oc1, 'yes')], [self.er1]) + + def test_filter_no(self): + self._test_filters([(self.oc1, 'no')], [self.er2]) From a6f52cfdc561fa436c474706c3f770805c43954f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:38:34 +0100 Subject: [PATCH 09/22] cof -- Fix urls naming in event template --- gestioncof/templates/event_status.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gestioncof/templates/event_status.html b/gestioncof/templates/event_status.html index 2a09b820..40bda7db 100644 --- a/gestioncof/templates/event_status.html +++ b/gestioncof/templates/event_status.html @@ -11,7 +11,7 @@ {% endif %} {% include "tristate_js.html" %}

Filtres

- + {% csrf_token %} {{ form.as_p }} From dfb9ccb0af1b8700823e929d85806c06c769979a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:15:57 +0100 Subject: [PATCH 10/22] Fix use of Widget.build_attrs in TriStateCheckbox Signature changed in Django 1.11. --- gestioncof/widgets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gestioncof/widgets.py b/gestioncof/widgets.py index 758fc4ad..cbc9cd93 100644 --- a/gestioncof/widgets.py +++ b/gestioncof/widgets.py @@ -20,6 +20,7 @@ class TriStateCheckbox(Widget): def render(self, name, value, attrs=None, choices=()): if value is None: value = 'none' - final_attrs = self.build_attrs(attrs, value=value) + attrs['value'] = value + final_attrs = self.build_attrs(self.attrs, attrs) output = ["" % flatatt(final_attrs)] return mark_safe('\n'.join(output)) From 4084444dc3c1fd5f5af2e5714cee2fc163da1180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 20 Jan 2018 17:29:15 +0100 Subject: [PATCH 11/22] Fix autocomplete in registration views. django-autocomplete-light v3.x doesn't include anymore the $('').yourlabsAutocomplete() function, leading to issues in cof registration and kfet account creation views. Adding jquery-autocomplete-light fixes these issues. See: - (dal) https://github.com/yourlabs/django-autocomplete-light - (jal) https://github.com/yourlabs/jquery-autocomplete-light --- cof/settings/common.py | 2 + gestioncof/templates/registration.html | 2 +- kfet/templates/kfet/account_create.html | 2 +- .../3.5.0/dist/jquery.autocomplete-light.js | 1698 +++++++++++++++++ .../dist/jquery.autocomplete-light.min.js | 9 + 5 files changed, 1711 insertions(+), 2 deletions(-) create mode 100644 shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.js create mode 100644 shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js diff --git a/cof/settings/common.py b/cof/settings/common.py index 48242fc3..aec299c1 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -56,6 +56,8 @@ BASE_DIR = os.path.dirname( # Application definition INSTALLED_APPS = [ + 'shared', + 'gestioncof', # Must be before 'django.contrib.admin'. diff --git a/gestioncof/templates/registration.html b/gestioncof/templates/registration.html index 8f05dfb0..99ab3e73 100644 --- a/gestioncof/templates/registration.html +++ b/gestioncof/templates/registration.html @@ -4,7 +4,7 @@ {% block page_size %}col-sm-8{% endblock %} {% block extra_head %} - + {% endblock %} {% block realcontent %} diff --git a/kfet/templates/kfet/account_create.html b/kfet/templates/kfet/account_create.html index 59fc1d56..b09713df 100644 --- a/kfet/templates/kfet/account_create.html +++ b/kfet/templates/kfet/account_create.html @@ -5,7 +5,7 @@ {% block header-title %}Création d'un compte{% endblock %} {% block extra_head %} - + {% endblock %} {% block main %} diff --git a/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.js b/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.js new file mode 100644 index 00000000..a916bff5 --- /dev/null +++ b/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.js @@ -0,0 +1,1698 @@ +/* + * jquery-autocomplete-light - v3.5.0 + * Dead simple autocompletion and widgets for jQuery + * http://yourlabs.org + * + * Made by James Pic + * Under MIT License + */ +/* +Here is the list of the major difference with other autocomplete scripts: + +- don't do anything but fire a signal when a choice is selected: it's +left as an exercise to the developer to implement whatever he wants when +that happens +- don't generate the autocomplete HTML, it should be generated by the server + +Let's establish the vocabulary used in this script, so that we speak the +same language: + +- The text input element is "input", +- The box that contains a list of choices is "box", +- Each result in the "autocomplete" is a "choice", +- With a capital A, "Autocomplete", is the class or an instance of the +class. + +Here is a fantastic schema in ASCII art: + + +---------------------+ <----- Input + | Your city name ? <---------- Placeholder + +---------------------+ + | Paris, France | <----- Autocomplete + | Paris, TX, USA | + | Paris, TN, USA | + | Paris, KY, USA <------------ Choice + | Paris, IL, USA | + +---------------------+ + +This script defines three signals: + +- hilightChoice: when a choice is hilight, or that the user +navigates into a choice with the keyboard, +- dehilightChoice: when a choice was hilighed, and that the user +navigates into another choice with the keyboard or mouse, +- selectChoice: when the user clicks on a choice, or that he pressed +enter on a hilighted choice. + +They all work the same, here's a trivial example: + + $('#your-autocomplete').bind( + 'selectChoice', + function(e, choice, autocomplete) { + alert('You selected: ' + choice.html()); + } + ); + +Note that 'e' is the variable containing the event object. + +Also, note that this script is composed of two main parts: + +- The Autocomplete class that handles all interaction, defined as +`Autocomplete`, +- The jQuery plugin that manages Autocomplete instance, defined as +`$.fn.yourlabsAutocomplete` +*/ + +if (window.isOpera === undefined) { + var isOpera = (navigator.userAgent.indexOf('Opera')>=0) && parseFloat(navigator.appVersion); +} + +if (window.isIE === undefined) { + var isIE = ((document.all) && (!isOpera)) && parseFloat(navigator.appVersion.split('MSIE ')[1].split(';')[0]); +} + +if (window.findPosX === undefined) { + window.findPosX = function(obj) { + var curleft = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curleft += obj.offsetLeft - ((isOpera) ? 0 : obj.scrollLeft); + obj = obj.offsetParent; + } + // IE offsetParent does not include the top-level + if (isIE && obj.parentElement){ + curleft += obj.offsetLeft - obj.scrollLeft; + } + } else if (obj.x) { + curleft += obj.x; + } + return curleft; + } +} + +if (window.findPosY === undefined) { + window.findPosY = function(obj) { + var curtop = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curtop += obj.offsetTop - ((isOpera) ? 0 : obj.scrollTop); + obj = obj.offsetParent; + } + // IE offsetParent does not include the top-level + if (isIE && obj.parentElement){ + curtop += obj.offsetTop - obj.scrollTop; + } + } else if (obj.y) { + curtop += obj.y; + } + return curtop; + } +} + +// Our class will live in the yourlabs global namespace. +if (window.yourlabs === undefined) window.yourlabs = {}; + +// Fix #25: Prevent accidental inclusion of autocomplete_light/static.html +if (window.yourlabs.Autocomplete !== undefined) + console.log('WARNING ! You are loading autocomplete.js **again**.'); + +yourlabs.getInternetExplorerVersion = function() +// Returns the version of Internet Explorer or a -1 +// (indicating the use of another browser). +{ + var rv = -1; // Return value assumes failure. + if (navigator.appName === 'Microsoft Internet Explorer') + { + var ua = navigator.userAgent; + var re = new RegExp('MSIE ([0-9]{1,}[.0-9]{0,})'); + if (re.exec(ua) !== null) + rv = parseFloat( RegExp.$1 ); + } + return rv; +}; + +$.fn.yourlabsRegistry = function(key, value) { + var ie = yourlabs.getInternetExplorerVersion(); + + if (ie === -1 || ie > 8) { + // If not on IE8 and friends, that's all we need to do. + return value === undefined ? this.data(key) : this.data(key, value); + } + + if ($.fn.yourlabsRegistry.data === undefined) { + $.fn.yourlabsRegistry.data = {}; + } + + if ($.fn.yourlabsRegistry.guid === undefined) { + $.fn.yourlabsRegistry.guid = function() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + function(c) { + var r = Math.random()*16|0, v = c === 'x' ? r : (r&0x3|0x8); + return v.toString(16); + } + ); + }; + } + + var attributeName = 'data-yourlabs-' + key + '-registry-id'; + var id = this.attr(attributeName); + + if (id === undefined) { + id = $.fn.yourlabsRegistry.guid(); + this.attr(attributeName, id); + } + + if (value !== undefined) { + $.fn.yourlabsRegistry.data[id] = value; + } + + return $.fn.yourlabsRegistry.data[id]; +}; + +/* +The autocomplete class constructor: + +- takes a takes a text input element as argument, +- sets attributes and methods for this instance. + +The reason you want to learn about all this script is that you will then be +able to override any variable or function in it on a case-per-case basis. +However, overriding is the job of the jQuery plugin so the procedure is +described there. +*/ +yourlabs.Autocomplete = function (input) { + /* + The text input element that should have an autocomplete. + */ + this.input = input; + + // The value of the input. It is kept as an attribute for optimisation + // purposes. + this.value = null; + + /* + It is possible to wait until a certain number of characters have been + typed in the input before making a request to the server, to limit the + number of requests. + + However, you may want the autocomplete to behave like a select. If you + want that a simple click shows the autocomplete, set this to 0. + */ + this.minimumCharacters = 2; + + /* + In a perfect world, we would hide the autocomplete when the input looses + focus (on blur). But in reality, if the user clicks on a choice, the + input looses focus, and that would hide the autocomplete, *before* we + can intercept the click on the choice. + + When the input looses focus, wait for this number of milliseconds before + hiding the autocomplete. + */ + this.hideAfter = 200; + + /* + Normally the autocomplete box aligns with the left edge of the input. To + align with the right edge of the input instead, change this variable. + */ + this.alignRight = false; + + /* + The server should have a URL that takes the input value, and responds + with the list of choices as HTML. In most cases, an absolute URL is + better. + */ + this.url = false; + + /* + Although this script will make sure that it doesn't have multiple ajax + requests at the time, it also supports debouncing. + + Set a number of milliseconds here, it is the number of milliseconds that it + will wait before querying the server. The higher it is, the less it will + spam the server but the more the user will wait. + */ + this.xhrWait = 200; + + /* + As the server responds with plain HTML, we need a selector to find the + choices that it contains. + + For example, if the URL returns an HTML body where every result is in a + div of class "choice", then this should be set to '.choice'. + */ + this.choiceSelector = '.choice'; + + /* + When the user hovers a choice, it is nice to hilight it, for + example by changing it's background color. That's the job of CSS code. + + However, the CSS can not depend on the :hover because the user can + hilight choices with the keyboard by pressing the up and down + keys. + + To counter that problem, we specify a particular class that will be set + on a choice when it's 'hilighted', and unset when it's + 'dehilighted'. + */ + this.hilightClass = 'hilight'; + + /* + You can set this variable to true if you want the first choice + to be hilighted by default. + */ + this.autoHilightFirst = false; + + + /* + You can set this variable to false in order to allow opening of results + in new tabs or windows + */ + this.bindMouseDown = true; + + /* + The value of the input is passed to the server via a GET variable. This + is the name of the variable. + */ + this.queryVariable = 'q'; + + /* + This dict will also be passed to the server as GET variables. + + If this autocomplete depends on another user defined value, then the + other user defined value should be set in this dict. + + Consider a country select and a city autocomplete. The city autocomplete + should only fetch city choices that are in the selected country. To + achieve this, update the data with the value of the country select: + + $('select[name=country]').change(function() { + $('city[name=country]').yourlabsAutocomplete().data = { + country: $(this).val(), + } + }); + */ + this.data = {}; + + /* + To avoid several requests to be pending at the same time, the current + request is aborted before a new one is sent. This attribute will hold the + current XMLHttpRequest. + */ + this.xhr = false; + + /* + fetch() keeps a copy of the data sent to the server in this attribute. This + avoids double fetching the same autocomplete. + */ + this.lastData = {}; + + // The autocomplete box HTML. + this.box = $(''); + + /* + We'll append the box to the container and calculate an absolute position + every time the autocomplete is shown in the fixPosition method. + + By default, this traverses this.input's parents to find the nearest parent + with an 'absolute' or 'fixed' position. This prevents scrolling issues. If + we can't find a parent that would be correct to append to, default to + . + */ + this.container = this.input.parents().filter(function() { + var position = $(this).css('position'); + return position === 'absolute' || position === 'fixed'; + }).first(); + if (!this.container.length) this.container = $('body'); +}; + +/* +Rather than directly setting up the autocomplete (DOM events etc ...) in +the constructor, setup is done in this method. This allows to: + +- instanciate an Autocomplete, +- override attribute/methods of the instance, +- and *then* setup the instance. + */ +yourlabs.Autocomplete.prototype.initialize = function() { + var ie = yourlabs.getInternetExplorerVersion(); + + this.input + .on('blur.autocomplete', $.proxy(this.inputBlur, this)) + .on('focus.autocomplete', $.proxy(this.inputClick, this)) + .on('keydown.autocomplete', $.proxy(this.inputKeyup, this)); + + $(window).on('resize', $.proxy(function() { + if (this.box.is(':visible')) this.fixPosition(); + }, this)); + + // Currently, our positioning doesn't work well in Firefox. Since it's not + // the first option on mobile phones and small devices, we'll hide the bug + // until this is fixed. + if (/Firefox/i.test(navigator.userAgent)) + $(window).on('scroll', $.proxy(this.hide, this)); + + if (ie === -1 || ie > 9) { + this.input.on('input.autocomplete', $.proxy(this.refresh, this)); + } + else + { + var events = [ + 'keyup.autocomplete', + 'keypress.autocomplete', + 'cut.autocomplete', + 'paste.autocomplete' + ] + + this.input.on(events.join(' '), function($e) { + $.proxy(this.inputKeyup, this); + }) + } + + /* + Bind mouse events to fire signals. Because the same signals will be + sent if the user uses keyboard to work with the autocomplete. + */ + this.box + .on('mouseenter', this.choiceSelector, $.proxy(this.boxMouseenter, this)) + .on('mouseleave', this.choiceSelector, $.proxy(this.boxMouseleave, this)); + if(this.bindMouseDown){ + this.box + .on('mousedown', this.choiceSelector, $.proxy(this.boxClick, this)); + } + + /* + Initially - empty data queried + */ + this.data[this.queryVariable] = ''; +}; + +// Unbind callbacks on input. +yourlabs.Autocomplete.prototype.destroy = function(input) { + input + .unbind('blur.autocomplete') + .unbind('focus.autocomplete') + .unbind('input.autocomplete') + .unbind('keydown.autocomplete') + .unbind('keypress.autocomplete') + .unbind('keyup.autocomplete') +}; + +yourlabs.Autocomplete.prototype.inputBlur = function(e) { + window.setTimeout($.proxy(this.hide, this), this.hideAfter); +}; + +yourlabs.Autocomplete.prototype.inputClick = function(e) { + if (this.value === null) + this.value = this.getQuery(); + + if (this.value.length >= this.minimumCharacters) + this.show(); +}; + +// When mouse enters the box: +yourlabs.Autocomplete.prototype.boxMouseenter = function(e) { + // ... the first thing we want is to send the dehilight signal + // for any hilighted choice ... + var current = this.box.find('.' + this.hilightClass); + + this.input.trigger('dehilightChoice', + [current, this]); + + // ... and then sent the hilight signal for the choice. + this.input.trigger('hilightChoice', + [$(e.currentTarget), this]); +}; + +// When mouse leaves the box: +yourlabs.Autocomplete.prototype.boxMouseleave = function(e) { + // Send dehilightChoice when the mouse leaves a choice. + this.input.trigger('dehilightChoice', + [this.box.find('.' + this.hilightClass), this]); +}; + +// When mouse clicks in the box: +yourlabs.Autocomplete.prototype.boxClick = function(e) { + var current = this.box.find('.' + this.hilightClass); + + this.input.trigger('selectChoice', [current, this]); +}; + +// Return the value to pass to this.queryVariable. +yourlabs.Autocomplete.prototype.getQuery = function() { + // Return the input's value by default. + return this.input.val(); +}; + +yourlabs.Autocomplete.prototype.inputKeyup = function(e) { + if (!this.input.is(':visible')) + // Don't handle keypresses on hidden inputs (ie. with limited choices) + return; + + switch(e.keyCode) { + case 40: // down arrow + case 38: // up arrow + case 16: // shift + case 17: // ctrl + case 18: // alt + this.move(e); + break; + + case 9: // tab + case 13: // enter + if (!this.box.is(':visible')) return; + + var choice = this.box.find('.' + this.hilightClass); + + if (!choice.length) { + // Don't get in the way, let the browser submit form or focus + // on next element. + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this.input.trigger('selectChoice', [choice, this]); + break; + + case 27: // escape + if (!this.box.is(':visible')) return; + this.hide(); + break; + + default: + this.refresh(); + } +}; + +// This function is in charge of ensuring that a relevant autocomplete is +// shown. +yourlabs.Autocomplete.prototype.show = function(html) { + // First recalculate the absolute position since the autocomplete may + // have changed position. + this.fixPosition(); + + // Is autocomplete empty ? + var empty = $.trim(this.box.find(this.choiceSelector)).length === 0; + + // If the inner container is empty or data has changed and there is no + // current pending request, rely on fetch(), which should show the + // autocomplete as soon as it's done fetching. + if ((this.hasChanged() || empty) && !this.xhr) { + this.fetch(); + return; + } + + // And actually, fetch() will call show() with the response + // body as argument. + if (html !== undefined) { + this.box.html(html); + this.fixPosition(); + } + + // Don't display empty boxes. + if (this.box.is(':empty')) { + if (this.box.is(':visible')) { + this.hide(); + } + return; + } + + var current = this.box.find('.' + this.hilightClass); + var first = this.box.find(this.choiceSelector + ':first'); + if (first && !current.length && this.autoHilightFirst) { + first.addClass(this.hilightClass); + } + + // Show the inner and outer container only if necessary. + if (!this.box.is(':visible')) { + this.box.css('display', 'block'); + this.fixPosition(); + } +}; + +// This function is in charge of the opposite. +yourlabs.Autocomplete.prototype.hide = function() { + this.box.hide(); +}; + +// This function is in charge of hilighting the right result from keyboard +// navigation. +yourlabs.Autocomplete.prototype.move = function(e) { + if (this.value === null) + this.value = this.getQuery(); + + // If the autocomplete should not be displayed then return. + if (this.value.length < this.minimumCharacters) return true; + + // The current choice if any. + var current = this.box.find('.' + this.hilightClass); + + // Prevent default browser behaviours on TAB and RETURN if a choice is + // hilighted. + if ($.inArray(e.keyCode, [9,13]) > -1 && current.length) { + e.preventDefault(); + } + + // If not KEY_UP or KEY_DOWN, then return. + // NOTE: with Webkit, both keyCode and charCode are set to 38/40 for &/(. + // charCode is 0 for arrow keys. + // Ref: http://stackoverflow.com/a/12046935/15690 + var way; + if (e.keyCode === 38 && !e.charCode) way = 'up'; + else if (e.keyCode === 40 && !e.charCode) way = 'down'; + else return; + + // The first and last choices. If the user presses down on the last + // choice, then the first one will be hilighted. + var first = this.box.find(this.choiceSelector + ':first'); + var last = this.box.find(this.choiceSelector + ':last'); + + // The choice that should be hilighted after the move. + var target; + + // The autocomplete must be shown so that the user sees what choice + // he is hilighting. + this.show(); + + // If a choice is currently hilighted: + if (current.length) { + if (way === 'up') { + // The target choice becomes the first previous choice. + target = current.prevAll(this.choiceSelector + ':first'); + + // If none, then the last choice becomes the target. + if (!target.length) target = last; + } else { + // The target choice becomes the first next** choice. + target = current.nextAll(this.choiceSelector + ':first'); + + // If none, then the first choice becomes the target. + if (!target.length) target = first; + } + + // Trigger dehilightChoice on the currently hilighted choice. + this.input.trigger('dehilightChoice', + [current, this]); + } else { + target = way === 'up' ? last : first; + } + + // Avoid moving the cursor in the input. + e.preventDefault(); + + // Trigger hilightChoice on the target choice. + this.input.trigger('hilightChoice', + [target, this]); +}; + +/* +Calculate and set the outer container's absolute positionning. We're copying +the system from Django admin's JS widgets like the date calendar, which means: + +- the autocomplete box is an element appended to this.co, +- +*/ +yourlabs.Autocomplete.prototype.fixPosition = function() { + var el = this.input.get(0); + + var zIndex = this.input.parents().filter(function() { + return $(this).css('z-index') !== 'auto' && $(this).css('z-index') !== '0'; + }).first().css('z-index'); + + var absolute_parent = this.input.parents().filter(function(){ + return $(this).css('position') === 'absolute'; + }).get(0); + + var top = (findPosY(el) + this.input.outerHeight()) + 'px'; + var left = findPosX(el) + 'px'; + + if(absolute_parent !== undefined){ + var parentTop = findPosY(absolute_parent); + var parentLeft = findPosX(absolute_parent); + var inputBottom = findPosY(el) + this.input.outerHeight(); + var inputLeft = findPosX(el); + top = (inputBottom - parentTop) + 'px'; + left = (inputLeft - parentLeft) + 'px'; + } + + if (this.alignRight) { + left = (findPosX(el) + el.scrollLeft - (this.box.outerWidth() - this.input.outerWidth())) + 'px'; + } + + this.box.appendTo(this.container).css({ + position: 'absolute', + minWidth: parseInt(this.input.outerWidth()), + top: top, + left: left, + zIndex: zIndex + }); +}; + +// Proxy fetch(), with some sanity checks. +yourlabs.Autocomplete.prototype.refresh = function() { + // Set the new current value. + this.value = this.getQuery(); + + // If the input doesn't contain enought characters then abort, else fetch. + if (this.value.length < this.minimumCharacters) + this.hide(); + else + this.fetch(); +}; + +// Return true if the data for this query has changed from last query. +yourlabs.Autocomplete.prototype.hasChanged = function() { + for(var key in this.data) { + if (!(key in this.lastData) || this.data[key] !== this.lastData[key]) { + return true; + } + } + return false; +}; + +// Manage requests to this.url. +yourlabs.Autocomplete.prototype.fetch = function() { + // Add the current value to the data dict. + this.data[this.queryVariable] = this.value; + + // Ensure that this request is different from the previous one + if (!this.hasChanged()) { + // Else show the same box again. + this.show(); + return; + } + + this.lastData = {}; + for(var key in this.data) { + this.lastData[key] = this.data[key]; + } + + // Abort any unsent requests. + if (this.xhr && this.xhr.readyState === 0) this.xhr.abort(); + + // Abort any request that we planned to make. + if (this.timeoutId) clearTimeout(this.timeoutId); + + // Make an asynchronous GET request to this.url in this.xhrWait ms + this.timeoutId = setTimeout($.proxy(this.makeXhr, this), this.xhrWait); +}; + +// Wrapped ajax call to use with setTimeout in fetch(). +yourlabs.Autocomplete.prototype.makeXhr = function() { + this.input.addClass('xhr-pending'); + + this.xhr = $.ajax(this.url, { + type: 'GET', + data: this.data, + complete: $.proxy(this.fetchComplete, this) + }); +}; + +// Callback for the ajax response. +yourlabs.Autocomplete.prototype.fetchComplete = function(jqXHR, textStatus) { + if (this.xhr === jqXHR) { + // Current request finished. + this.xhr = false; + } else { + // Ignore response from earlier request. + return; + } + + // Current request done, nothing else pending. + this.input.removeClass('xhr-pending'); + + if (textStatus === 'abort') return; + this.show(jqXHR.responseText); +}; + +/* +The jQuery plugin that manages Autocomplete instances across the various +inputs. It is named 'yourlabsAutocomplete' rather than just 'autocomplete' +to live happily with other plugins that may define an autocomplete() jQuery +plugin. + +It takes an array as argument, the array may contain any attribute or +function that should override the Autocomplete builtin. For example: + + $('input#your-autocomplete').yourlabsAutocomplete({ + url: '/some/url/', + hide: function() { + this.outerContainer + }, + }) + +Also, it implements a simple identity map, which means that: + + // First call for an input instanciates the Autocomplete instance + $('input#your-autocomplete').yourlabsAutocomplete({ + url: '/some/url/', + }); + + // Other calls return the previously created Autocomplete instance + $('input#your-autocomplete').yourlabsAutocomplete().data = { + newData: $('#foo').val(), + } + +To destroy an autocomplete, call yourlabsAutocomplete('destroy'). +*/ +$.fn.yourlabsAutocomplete = function(overrides) { + if (this.length < 1) { + // avoid crashing when called on a non existing element + return; + } + + overrides = overrides ? overrides : {}; + var autocomplete = this.yourlabsRegistry('autocomplete'); + + if (overrides === 'destroy') { + if (autocomplete) { + autocomplete.destroy(this); + this.removeData('autocomplete'); + } + return; + } + + // Disable the browser's autocomplete features on that input. + this.attr('autocomplete', 'off'); + + // If no Autocomplete instance is defined for this id, make one. + if (autocomplete === undefined) { + // Instanciate Autocomplete. + autocomplete = new yourlabs.Autocomplete(this); + + // Extend the instance with data-autocomplete-* overrides + for (var key in this.data()) { + if (!key) continue; + if (key.substr(0, 12) !== 'autocomplete' || key === 'autocomplete') + continue; + var newKey = key.replace('autocomplete', ''); + newKey = newKey.charAt(0).toLowerCase() + newKey.slice(1); + autocomplete[newKey] = this.data(key); + } + + // Extend the instance with overrides. + autocomplete = $.extend(autocomplete, overrides); + + if (!autocomplete.url) { + alert('Autocomplete needs a url !'); + return; + } + + this.yourlabsRegistry('autocomplete', autocomplete); + + // All set, call initialize(). + autocomplete.initialize(); + } + + // Return the Autocomplete instance for this id from the registry. + return autocomplete; +}; + +// Binding some default behaviors. +$(document).ready(function() { + function removeHilightClass(e, choice, autocomplete) { + choice.removeClass(autocomplete.hilightClass); + } + $(document).bind('hilightChoice', function(e, choice, autocomplete) { + choice.addClass(autocomplete.hilightClass); + }); + $(document).bind('dehilightChoice', removeHilightClass); + $(document).bind('selectChoice', removeHilightClass); + $(document).bind('selectChoice', function(e, choice, autocomplete) { + autocomplete.hide(); + }); +}); + +$(document).ready(function() { + /* Credit: django.contrib.admin (BSD) */ + + var showAddAnotherPopup = function(triggeringLink) { + var name = triggeringLink.attr( 'id' ).replace(/^add_/, ''); + name = id_to_windowname(name); + href = triggeringLink.attr( 'href' ); + + if (href.indexOf('?') === -1) { + href += '?'; + } + + href += '&winName=' + name; + + var height = 500; + var width = 800; + var left = (screen.width/2)-(width/2); + var top = (screen.height/2)-(height/2); + var win = window.open(href, name, 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=yes, resizable=yes, copyhistory=no, width='+width+', height='+height+', top='+top+', left='+left) + + function removeOverlay() { + if (win.closed) { + $('#yourlabs_overlay').remove(); + } else { + setTimeout(removeOverlay, 500); + } + } + + $('body').append('
= 0; start--) { + if (value[start] === ',') { + break; + } + } + start = start < 0 ? 0 : start; + + // find end of word + for(var end=position; end <= value.length - 1; end++) { + if (value[end] === ',') { + break; + } + } + + while(value[start] === ',' || value[start] === ' ') start++; + while(value[end] === ',' || value[end] === ' ') end--; + + return [start, end + 1]; +} + +// TextWidget ties an input with an autocomplete. +yourlabs.TextWidget = function(input) { + this.input = input; + this.autocompleteOptions = { + getQuery: function() { + return this.input.getCursorWord(); + } + } +} + +// The widget is in charge of managing its Autocomplete. +yourlabs.TextWidget.prototype.initializeAutocomplete = function() { + this.autocomplete = this.input.yourlabsAutocomplete( + this.autocompleteOptions); + + // Add a class to ease css selection of autocompletes for widgets + this.autocomplete.box.addClass( + 'autocomplete-light-text-widget'); +}; + +// Bind Autocomplete.selectChoice signal to TextWidget.selectChoice() +yourlabs.TextWidget.prototype.bindSelectChoice = function() { + this.input.bind('selectChoice', function(e, choice) { + if (!choice.length) + return // placeholder: create choice here + + $(this).yourlabsTextWidget().selectChoice(choice); + }); +}; + +// Called when a choice is selected from the Autocomplete. +yourlabs.TextWidget.prototype.selectChoice = function(choice) { + var inputValue = this.input.val(); + var choiceValue = this.getValue(choice); + var positions = this.input.getCursorWordPositions(); + + var newValue = inputValue.substring(0, positions[0]); + newValue += choiceValue; + newValue += inputValue.substring(positions[1]); + + this.input.val(newValue); + this.input.focus(); +} + +// Return the value of an HTML choice, used to fill the input. +yourlabs.TextWidget.prototype.getValue = function(choice) { + return $.trim(choice.text()); +} + +// Initialize the widget. +yourlabs.TextWidget.prototype.initialize = function() { + this.initializeAutocomplete(); + this.bindSelectChoice(); +} + +// Destroy the widget. Takes a widget element because a cloned widget element +// will be dirty, ie. have wrong .input and .widget properties. +yourlabs.TextWidget.prototype.destroy = function(input) { + input + .unbind('selectChoice') + .yourlabsAutocomplete('destroy'); +} + +// TextWidget factory, registry and destroyer, as jQuery extension. +$.fn.yourlabsTextWidget = function(overrides) { + var widget; + overrides = overrides ? overrides : {}; + + if (overrides === 'destroy') { + widget = this.data('widget'); + if (widget) { + widget.destroy(this); + this.removeData('widget'); + } + return + } + + if (this.data('widget') === undefined) { + // Instanciate the widget + widget = new yourlabs.TextWidget(this); + + // Pares data-* + var data = this.data(); + var dataOverrides = { + autocompleteOptions: { + // workaround a display bug + minimumCharacters: 0, + getQuery: function() { + // Override getQuery since we need the autocomplete to filter + // choices based on the word the cursor is on, rather than the full + // input value. + return this.input.getCursorWord(); + } + } + }; + for (var key in data) { + if (!key) continue; + + if (key.substr(0, 12) === 'autocomplete') { + if (key === 'autocomplete') continue; + + var newKey = key.replace('autocomplete', ''); + newKey = newKey.replace(newKey[0], newKey[0].toLowerCase()) + dataOverrides.autocompleteOptions[newKey] = data[key]; + } else { + dataOverrides[key] = data[key]; + } + } + + // Allow attribute overrides + widget = $.extend(widget, dataOverrides); + + // Allow javascript object overrides + widget = $.extend(widget, overrides); + + this.data('widget', widget); + + // Setup for usage + widget.initialize(); + + // Widget is ready + widget.input.attr('data-widget-ready', 1); + widget.input.trigger('widget-ready'); + } + + return this.data('widget'); +} + +$(document).ready(function() { + $('body').on('initialize', 'input[data-widget-bootstrap=text]', function() { + /* + Only setup autocompletes on inputs which have + data-widget-bootstrap=text, if you want to initialize some + autocompletes with custom code, then set + data-widget-boostrap=yourbootstrap or something like that. + */ + $(this).yourlabsTextWidget(); + }); + + // Solid initialization, usage:: + // + // $(document).bind('yourlabsTextWidgetReady', function() { + // $('body').on('initialize', 'input[data-widget-bootstrap=text]', function() { + // $(this).yourlabsTextWidget({ + // yourCustomArgs: // ... + // }) + // }); + // }); + $(document).trigger('yourlabsTextWidgetReady'); + + $('.autocomplete-light-text-widget:not([id*="__prefix__"])').each(function() { + $(this).trigger('initialize'); + }); + + $(document).bind('DOMNodeInserted', function(e) { + var widget = $(e.target).find('.autocomplete-light-text-widget'); + + if (!widget.length) { + widget = $(e.target).is('.autocomplete-light-text-widget') ? $(e.target) : false; + + if (!widget) { + return; + } + } + + // Ignore inserted autocomplete box elements. + if (widget.is('.yourlabs-autocomplete')) { + return; + } + + // Ensure that the newly added widget is clean, in case it was cloned. + widget.yourlabsWidget('destroy'); + widget.find('input').yourlabsAutocomplete('destroy'); + + widget.trigger('initialize'); + }); +}) + +/* +Widget complements Autocomplete by enabling autocompletes to be used as +value holders. It looks very much like Autocomplete in its design. Thus, it +is recommended to read the source of Autocomplete first. + +Widget behaves like the autocomplete in facebook profile page, which all +users should be able to use. + +Behind the scenes, Widget maintains a normal hidden select which makes it +simple to play with on the server side like on the client side. If a value +is added and selected in the select element, then it is added to the deck, +and vice-versa. + +It needs some elements, and established vocabulary: + +- ".autocomplete-light-widget" element wraps all the HTML necessary for the + widget, +- ".deck" contains the list of selected choice(s) as HTML, +- "input" should be the text input that has the Autocomplete, +- "select" a (optionnaly multiple) select +- ".remove" a (preferabely hidden) element that contains a value removal + indicator, like an "X" sign or a trashcan icon, it is used to prefix every + children of the deck +- ".choice-template" a (preferabely hidden) element that contains the template + for choices which are added directly in the select, as they should be + copied in the deck, + +To avoid complexity, this script relies on extra HTML attributes, and +particularely one called 'data-value'. Learn more about data attributes: +http://dev.w3.org/html5/spec/global-attributes.html#embedding-custom-non-visible-data-with-the-data-attributes + +When a choice is selected from the Autocomplete, its element is cloned +and appended to the deck - "deck" contains "choices". It is important that the +choice elements of the autocomplete all contain a data-value attribute. +The value of data-value is used to fill the selected options in the hidden +select field. + +If choices may not all have a data-value attribute, then you can +override Widget.getValue() to implement your own logic. +*/ + +// Our class will live in the yourlabs global namespace. +if (window.yourlabs === undefined) window.yourlabs = {}; + +/* +Instanciate a Widget. +*/ +yourlabs.Widget = function(widget) { + // These attributes where described above. + this.widget = widget; + this.input = this.widget.find('input[data-autocomplete-url]'); + this.select = this.widget.find('select'); + this.deck = this.widget.find('.deck'); + this.choiceTemplate = this.widget.find('.choice-template .choice'); + + // The number of choices that the user may select with this widget. Set 0 + // for no limit. In the case of a foreign key you want to set it to 1. + this.maximumValues = 0; + + // Clear input when choice made? 1 for yes, 0 for no + this.clearInputOnSelectChoice = '1'; +} + +// When a choice is selected from the autocomplete of this widget, +// getValue() is called to add and select the option in the select. +yourlabs.Widget.prototype.getValue = function(choice) { + return choice.attr('data-value'); +}; + +// The widget is in charge of managing its Autocomplete. +yourlabs.Widget.prototype.initializeAutocomplete = function() { + this.autocomplete = this.input.yourlabsAutocomplete() + + // Add a class to ease css selection of autocompletes for widgets + this.autocomplete.box.addClass('autocomplete-light-widget'); +}; + +// Bind Autocomplete.selectChoice signal to Widget.selectChoice() +yourlabs.Widget.prototype.bindSelectChoice = function() { + this.input.bind('selectChoice', function(e, choice) { + if (!choice.length) + return // placeholder: create choice here + + var widget = $(this).parents('.autocomplete-light-widget' + ).yourlabsWidget(); + + widget.selectChoice(choice); + + widget.widget.trigger('widgetSelectChoice', [choice, widget]); + }); +}; + +// Called when a choice is selected from the Autocomplete. +yourlabs.Widget.prototype.selectChoice = function(choice) { + // Get the value for this choice. + var value = this.getValue(choice); + + if (!value) { + if (window.console) console.log('yourlabs.Widget.getValue failed'); + return; + } + + this.freeDeck(); + this.addToDeck(choice, value); + this.addToSelect(choice, value); + + var index = $(':input:visible').index(this.input); + this.resetDisplay(); + + if (this.clearInputOnSelectChoice === '1') { + this.input.val(''); + this.autocomplete.value = ''; + } + + if (this.input.is(':visible')) { + this.input.focus(); + } else { + var next = $(':input:visible:eq('+ index +')'); + next.focus(); + } + + if (! this.select.is('[multiple]')) { + this.input.prop('disabled', true); + } +} + +// Unselect a value if the maximum number of selected values has been +// reached. +yourlabs.Widget.prototype.freeDeck = function() { + var slots = this.maximumValues - this.deck.children().length; + + if (this.maximumValues && slots < 1) { + // We'll remove the first choice which is supposed to be the oldest + var choice = $(this.deck.children()[0]); + + this.deselectChoice(choice); + } +} + +// Empty the search input and hide it if maximumValues has been reached. +yourlabs.Widget.prototype.resetDisplay = function() { + var selected = this.select.find('option:selected').length; + + if (this.maximumValues && selected === this.maximumValues) { + this.input.hide(); + } else { + this.input.show(); + } + + this.deck.show(); + + // Also fix the position if the autocomplete is shown. + if (this.autocomplete.box.is(':visible')) this.autocomplete.fixPosition(); +} + +yourlabs.Widget.prototype.deckChoiceHtml = function(choice, value) { + var deckChoice = choice.clone(); + + this.addRemove(deckChoice); + + return deckChoice; +} + +yourlabs.Widget.prototype.optionChoice = function(option) { + var optionChoice = this.choiceTemplate.clone(); + + var target = optionChoice.find('.append-option-html'); + + if (target.length) { + target.append(option.html()); + } else { + optionChoice.html(option.html()); + } + + return optionChoice; +} + +yourlabs.Widget.prototype.addRemove = function(choices) { + var removeTemplate = this.widget.find('.remove:last') + .clone().css('display', 'inline-block'); + + var target = choices.find('.prepend-remove'); + + if (target.length) { + target.prepend(removeTemplate); + } else { + // Add the remove icon to each choice + choices.prepend(removeTemplate); + } +} + +// Add a selected choice of a given value to the deck. +yourlabs.Widget.prototype.addToDeck = function(choice, value) { + var existing_choice = this.deck.find('[data-value="'+value+'"]'); + + // Avoid duplicating choices in the deck. + if (!existing_choice.length) { + var deckChoice = this.deckChoiceHtml(choice); + + // In case getValue() actually **created** the value, for example + // with a post request. + deckChoice.attr('data-value', value); + + this.deck.append(deckChoice); + } +} + +// Add a selected choice of a given value to the deck. +yourlabs.Widget.prototype.addToSelect = function(choice, value) { + var option = this.select.find('option[value="'+value+'"]'); + + if (! option.length) { + this.select.append( + ''); + option = this.select.find('option[value="'+value+'"]'); + } + + option.attr('selected', 'selected'); + + this.select.trigger('change'); + this.updateAutocompleteExclude(); +} + +// Called when the user clicks .remove in a deck choice. +yourlabs.Widget.prototype.deselectChoice = function(choice) { + var value = this.getValue(choice); + + this.select.find('option[value="'+value+'"]').remove(); + this.select.trigger('change'); + + choice.remove(); + + if (this.deck.children().length === 0) { + this.deck.hide(); + } + + this.updateAutocompleteExclude(); + this.resetDisplay(); + + this.input.prop('disabled', false); + + this.widget.trigger('widgetDeselectChoice', [choice, this]); +}; + +yourlabs.Widget.prototype.updateAutocompleteExclude = function() { + var widget = this; + var choices = this.deck.find(this.autocomplete.choiceSelector); + + this.autocomplete.data.exclude = $.map(choices, function(choice) { + return widget.getValue($(choice)); + }); +} + +yourlabs.Widget.prototype.initialize = function() { + this.initializeAutocomplete(); + + // Working around firefox tempering form values after reload + var widget = this; + this.deck.find(this.autocomplete.choiceSelector).each(function() { + var value = widget.getValue($(this)); + var option = widget.select.find('option[value="'+value+'"]'); + if (!option.prop('selected')) option.prop('selected', true); + }); + + var choices = this.deck.find( + this.input.yourlabsAutocomplete().choiceSelector); + + this.addRemove(choices); + this.resetDisplay(); + + if (widget.select.val() && ! this.select.is('[multiple]')) { + this.input.prop('disabled', true); + } + + this.bindSelectChoice(); +} + +// Destroy the widget. Takes a widget element because a cloned widget element +// will be dirty, ie. have wrong .input and .widget properties. +yourlabs.Widget.prototype.destroy = function(widget) { + widget.find('input') + .unbind('selectChoice') + .yourlabsAutocomplete('destroy'); +} + +// Get or create or destroy a widget instance. +// +// On first call, yourlabsWidget() will instanciate a widget applying all +// passed overrides. +// +// On later calls, yourlabsWidget() will return the previously created widget +// instance, which is stored in widget.data('widget'). +// +// Calling yourlabsWidget('destroy') will destroy the widget. Useful if the +// element was blindly cloned with .clone(true) for example. +$.fn.yourlabsWidget = function(overrides) { + overrides = overrides ? overrides : {}; + + var widget = this.yourlabsRegistry('widget'); + + if (overrides === 'destroy') { + if (widget) { + widget.destroy(this); + this.removeData('widget'); + } + return + } + + if (widget === undefined) { + // Instanciate the widget + widget = new yourlabs.Widget(this); + + // Extend the instance with data-widget-* overrides + for (var key in this.data()) { + if (!key) continue; + if (key.substr(0, 6) !== 'widget' || key === 'widget') continue; + var newKey = key.replace('widget', ''); + newKey = newKey.charAt(0).toLowerCase() + newKey.slice(1); + widget[newKey] = this.data(key); + } + + // Allow javascript object overrides + widget = $.extend(widget, overrides); + + $(this).yourlabsRegistry('widget', widget); + + // Setup for usage + widget.initialize(); + + // Widget is ready + widget.widget.attr('data-widget-ready', 1); + widget.widget.trigger('widget-ready'); + } + + return widget; +} + +$(document).ready(function() { + $('body').on('initialize', '.autocomplete-light-widget[data-widget-bootstrap=normal]', function() { + /* + Only setup widgets which have data-widget-bootstrap=normal, if you want to + initialize some Widgets with custom code, then set + data-widget-boostrap=yourbootstrap or something like that. + */ + $(this).yourlabsWidget(); + }); + + // Call Widget.deselectChoice when .remove is clicked + $('body').on('click', '.autocomplete-light-widget .deck .remove', function() { + var widget = $(this).parents('.autocomplete-light-widget' + ).yourlabsWidget(); + + var selector = widget.input.yourlabsAutocomplete().choiceSelector; + var choice = $(this).parents(selector); + + widget.deselectChoice(choice); + }); + + // Solid initialization, usage: + // + // + // $(document).bind('yourlabsWidgetReady', function() { + // $('.your.autocomplete-light-widget').on('initialize', function() { + // $(this).yourlabsWidget({ + // yourCustomArgs: // ... + // }) + // }); + // }); + $(document).trigger('yourlabsWidgetReady'); + + $('.autocomplete-light-widget:not([id*="__prefix__"])').each(function() { + $(this).trigger('initialize'); + }); + + $(document).bind('DOMNodeInserted', function(e) { + /* + Support values added directly in the select via js (ie. choices created in + modal or popup). + + For this, we listen to DOMNodeInserted and intercept insert of ') + */ + var widget; + + if ($(e.target).is('option')) { // added an option ? + widget = $(e.target).parents('.autocomplete-light-widget'); + + if (!widget.length) { + return; + } + + widget = widget.yourlabsWidget(); + var option = $(e.target); + var value = option.attr('value'); + var choice = widget.deck.find('[data-value="'+value+'"]'); + + if (!choice.length) { + var deckChoice = widget.optionChoice(option); + + deckChoice.attr('data-value', value); + + widget.selectChoice(deckChoice); + } + } else { // added a widget ? + var notReady = '.autocomplete-light-widget:not([data-widget-ready])' + widget = $(e.target).find(notReady); + + if (!widget.length) { + return; + } + + // Ignore inserted autocomplete box elements. + if (widget.is('.yourlabs-autocomplete')) { + return; + } + + // Ensure that the newly added widget is clean, in case it was + // cloned with data. + widget.yourlabsWidget('destroy'); + widget.find('input').yourlabsAutocomplete('destroy'); + + // added a widget: initialize the widget. + widget.trigger('initialize'); + } + }); + + var ie = yourlabs.getInternetExplorerVersion(); + if (ie !== -1 && ie < 9) { + observe = [ + '.autocomplete-light-widget:not([data-yourlabs-skip])', + '.autocomplete-light-widget option:not([data-yourlabs-skip])' + ].join(); + $(observe).attr('data-yourlabs-skip', 1); + + var ieDOMNodeInserted = function() { + // http://msdn.microsoft.com/en-us/library/ms536957 + $(observe).each(function() { + $(document).trigger(jQuery.Event('DOMNodeInserted', {target: $(this)})); + $(this).attr('data-yourlabs-skip', 1); + }); + + setTimeout(ieDOMNodeInserted, 500); + } + setTimeout(ieDOMNodeInserted, 500); + } + +}); diff --git a/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js b/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js new file mode 100644 index 00000000..a5bc6774 --- /dev/null +++ b/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js @@ -0,0 +1,9 @@ +/* + * jquery-autocomplete-light - v3.5.0 + * Dead simple autocompletion and widgets for jQuery + * http://yourlabs.org + * + * Made by James Pic + * Under MIT License + */ +if(void 0===window.isOpera)var isOpera=navigator.userAgent.indexOf("Opera")>=0&&parseFloat(navigator.appVersion);if(void 0===window.isIE)var isIE=document.all&&!isOpera&&parseFloat(navigator.appVersion.split("MSIE ")[1].split(";")[0]);void 0===window.findPosX&&(window.findPosX=function(a){var b=0;if(a.offsetParent){for(;a.offsetParent;)b+=a.offsetLeft-(isOpera?0:a.scrollLeft),a=a.offsetParent;isIE&&a.parentElement&&(b+=a.offsetLeft-a.scrollLeft)}else a.x&&(b+=a.x);return b}),void 0===window.findPosY&&(window.findPosY=function(a){var b=0;if(a.offsetParent){for(;a.offsetParent;)b+=a.offsetTop-(isOpera?0:a.scrollTop),a=a.offsetParent;isIE&&a.parentElement&&(b+=a.offsetTop-a.scrollTop)}else a.y&&(b+=a.y);return b}),void 0===window.yourlabs&&(window.yourlabs={}),void 0!==window.yourlabs.Autocomplete&&console.log("WARNING ! You are loading autocomplete.js **again**."),yourlabs.getInternetExplorerVersion=function(){var a=-1;if("Microsoft Internet Explorer"===navigator.appName){var b=navigator.userAgent;null!==new RegExp("MSIE ([0-9]{1,}[.0-9]{0,})").exec(b)&&(a=parseFloat(RegExp.$1))}return a},$.fn.yourlabsRegistry=function(a,b){var c=yourlabs.getInternetExplorerVersion();if(-1===c||c>8)return void 0===b?this.data(a):this.data(a,b);void 0===$.fn.yourlabsRegistry.data&&($.fn.yourlabsRegistry.data={}),void 0===$.fn.yourlabsRegistry.guid&&($.fn.yourlabsRegistry.guid=function(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(a){var b=16*Math.random()|0;return("x"===a?b:3&b|8).toString(16)})});var d="data-yourlabs-"+a+"-registry-id",e=this.attr(d);return void 0===e&&(e=$.fn.yourlabsRegistry.guid(),this.attr(d,e)),void 0!==b&&($.fn.yourlabsRegistry.data[e]=b),$.fn.yourlabsRegistry.data[e]},yourlabs.Autocomplete=function(a){this.input=a,this.value=null,this.minimumCharacters=2,this.hideAfter=200,this.alignRight=!1,this.url=!1,this.xhrWait=200,this.choiceSelector=".choice",this.hilightClass="hilight",this.autoHilightFirst=!1,this.bindMouseDown=!0,this.queryVariable="q",this.data={},this.xhr=!1,this.lastData={},this.box=$(''),this.container=this.input.parents().filter(function(){var a=$(this).css("position");return"absolute"===a||"fixed"===a}).first(),this.container.length||(this.container=$("body"))},yourlabs.Autocomplete.prototype.initialize=function(){var a=yourlabs.getInternetExplorerVersion();if(this.input.on("blur.autocomplete",$.proxy(this.inputBlur,this)).on("focus.autocomplete",$.proxy(this.inputClick,this)).on("keydown.autocomplete",$.proxy(this.inputKeyup,this)),$(window).on("resize",$.proxy(function(){this.box.is(":visible")&&this.fixPosition()},this)),/Firefox/i.test(navigator.userAgent)&&$(window).on("scroll",$.proxy(this.hide,this)),-1===a||a>9)this.input.on("input.autocomplete",$.proxy(this.refresh,this));else{var b=["keyup.autocomplete","keypress.autocomplete","cut.autocomplete","paste.autocomplete"];this.input.on(b.join(" "),function(a){$.proxy(this.inputKeyup,this)})}this.box.on("mouseenter",this.choiceSelector,$.proxy(this.boxMouseenter,this)).on("mouseleave",this.choiceSelector,$.proxy(this.boxMouseleave,this)),this.bindMouseDown&&this.box.on("mousedown",this.choiceSelector,$.proxy(this.boxClick,this)),this.data[this.queryVariable]=""},yourlabs.Autocomplete.prototype.destroy=function(a){a.unbind("blur.autocomplete").unbind("focus.autocomplete").unbind("input.autocomplete").unbind("keydown.autocomplete").unbind("keypress.autocomplete").unbind("keyup.autocomplete")},yourlabs.Autocomplete.prototype.inputBlur=function(a){window.setTimeout($.proxy(this.hide,this),this.hideAfter)},yourlabs.Autocomplete.prototype.inputClick=function(a){null===this.value&&(this.value=this.getQuery()),this.value.length>=this.minimumCharacters&&this.show()},yourlabs.Autocomplete.prototype.boxMouseenter=function(a){var b=this.box.find("."+this.hilightClass);this.input.trigger("dehilightChoice",[b,this]),this.input.trigger("hilightChoice",[$(a.currentTarget),this])},yourlabs.Autocomplete.prototype.boxMouseleave=function(a){this.input.trigger("dehilightChoice",[this.box.find("."+this.hilightClass),this])},yourlabs.Autocomplete.prototype.boxClick=function(a){var b=this.box.find("."+this.hilightClass);this.input.trigger("selectChoice",[b,this])},yourlabs.Autocomplete.prototype.getQuery=function(){return this.input.val()},yourlabs.Autocomplete.prototype.inputKeyup=function(a){if(this.input.is(":visible"))switch(a.keyCode){case 40:case 38:case 16:case 17:case 18:this.move(a);break;case 9:case 13:if(!this.box.is(":visible"))return;var b=this.box.find("."+this.hilightClass);if(!b.length)return;a.preventDefault(),a.stopPropagation(),this.input.trigger("selectChoice",[b,this]);break;case 27:if(!this.box.is(":visible"))return;this.hide();break;default:this.refresh()}},yourlabs.Autocomplete.prototype.show=function(a){this.fixPosition();var b=0===$.trim(this.box.find(this.choiceSelector)).length;if((this.hasChanged()||b)&&!this.xhr)return void this.fetch();if(void 0!==a&&(this.box.html(a),this.fixPosition()),this.box.is(":empty"))return void(this.box.is(":visible")&&this.hide());var c=this.box.find("."+this.hilightClass),d=this.box.find(this.choiceSelector+":first");d&&!c.length&&this.autoHilightFirst&&d.addClass(this.hilightClass),this.box.is(":visible")||(this.box.css("display","block"),this.fixPosition())},yourlabs.Autocomplete.prototype.hide=function(){this.box.hide()},yourlabs.Autocomplete.prototype.move=function(a){if(null===this.value&&(this.value=this.getQuery()),this.value.length-1&&b.length&&a.preventDefault();var c;if(38!==a.keyCode||a.charCode){if(40!==a.keyCode||a.charCode)return;c="down"}else c="up";var d,e=this.box.find(this.choiceSelector+":first"),f=this.box.find(this.choiceSelector+":last");this.show(),b.length?("up"===c?(d=b.prevAll(this.choiceSelector+":first"),d.length||(d=f)):(d=b.nextAll(this.choiceSelector+":first"),d.length||(d=e)),this.input.trigger("dehilightChoice",[b,this])):d="up"===c?f:e,a.preventDefault(),this.input.trigger("hilightChoice",[d,this])},yourlabs.Autocomplete.prototype.fixPosition=function(){var a=this.input.get(0),b=this.input.parents().filter(function(){return"auto"!==$(this).css("z-index")&&"0"!==$(this).css("z-index")}).first().css("z-index"),c=this.input.parents().filter(function(){return"absolute"===$(this).css("position")}).get(0),d=findPosY(a)+this.input.outerHeight()+"px",e=findPosX(a)+"px";if(void 0!==c){var f=findPosY(c),g=findPosX(c),h=findPosY(a)+this.input.outerHeight(),i=findPosX(a);d=h-f+"px",e=i-g+"px"}this.alignRight&&(e=findPosX(a)+a.scrollLeft-(this.box.outerWidth()-this.input.outerWidth())+"px"),this.box.appendTo(this.container).css({position:"absolute",minWidth:parseInt(this.input.outerWidth()),top:d,left:e,zIndex:b})},yourlabs.Autocomplete.prototype.refresh=function(){this.value=this.getQuery(),this.value.length=0&&","!==b[c];c--);c=c<0?0:c;for(var d=a;d<=b.length-1&&","!==b[d];d++);for(;","===b[c]||" "===b[c];)c++;for(;","===b[d]||" "===b[d];)d--;return[c,d+1]},yourlabs.TextWidget=function(a){this.input=a,this.autocompleteOptions={getQuery:function(){return this.input.getCursorWord()}}},yourlabs.TextWidget.prototype.initializeAutocomplete=function(){this.autocomplete=this.input.yourlabsAutocomplete(this.autocompleteOptions),this.autocomplete.box.addClass("autocomplete-light-text-widget")},yourlabs.TextWidget.prototype.bindSelectChoice=function(){this.input.bind("selectChoice",function(a,b){b.length&&$(this).yourlabsTextWidget().selectChoice(b)})},yourlabs.TextWidget.prototype.selectChoice=function(a){var b=this.input.val(),c=this.getValue(a),d=this.input.getCursorWordPositions(),e=b.substring(0,d[0]);e+=c,e+=b.substring(d[1]),this.input.val(e),this.input.focus()},yourlabs.TextWidget.prototype.getValue=function(a){return $.trim(a.text())},yourlabs.TextWidget.prototype.initialize=function(){this.initializeAutocomplete(),this.bindSelectChoice()},yourlabs.TextWidget.prototype.destroy=function(a){a.unbind("selectChoice").yourlabsAutocomplete("destroy")},$.fn.yourlabsTextWidget=function(a){var b;if("destroy"===(a=a||{}))return void((b=this.data("widget"))&&(b.destroy(this),this.removeData("widget")));if(void 0===this.data("widget")){b=new yourlabs.TextWidget(this);var c=this.data(),d={autocompleteOptions:{minimumCharacters:0,getQuery:function(){return this.input.getCursorWord()}}};for(var e in c)if(e)if("autocomplete"===e.substr(0,12)){if("autocomplete"===e)continue;var f=e.replace("autocomplete","");f=f.replace(f[0],f[0].toLowerCase()),d.autocompleteOptions[f]=c[e]}else d[e]=c[e];b=$.extend(b,d),b=$.extend(b,a),this.data("widget",b),b.initialize(),b.input.attr("data-widget-ready",1),b.input.trigger("widget-ready")}return this.data("widget")},$(document).ready(function(){$("body").on("initialize","input[data-widget-bootstrap=text]",function(){$(this).yourlabsTextWidget()}),$(document).trigger("yourlabsTextWidgetReady"),$('.autocomplete-light-text-widget:not([id*="__prefix__"])').each(function(){$(this).trigger("initialize")}),$(document).bind("DOMNodeInserted",function(a){var b=$(a.target).find(".autocomplete-light-text-widget");(b.length||(b=!!$(a.target).is(".autocomplete-light-text-widget")&&$(a.target)))&&(b.is(".yourlabs-autocomplete")||(b.yourlabsWidget("destroy"),b.find("input").yourlabsAutocomplete("destroy"),b.trigger("initialize")))})}),void 0===window.yourlabs&&(window.yourlabs={}),yourlabs.Widget=function(a){this.widget=a,this.input=this.widget.find("input[data-autocomplete-url]"),this.select=this.widget.find("select"),this.deck=this.widget.find(".deck"),this.choiceTemplate=this.widget.find(".choice-template .choice"),this.maximumValues=0,this.clearInputOnSelectChoice="1"},yourlabs.Widget.prototype.getValue=function(a){return a.attr("data-value")},yourlabs.Widget.prototype.initializeAutocomplete=function(){this.autocomplete=this.input.yourlabsAutocomplete(),this.autocomplete.box.addClass("autocomplete-light-widget")},yourlabs.Widget.prototype.bindSelectChoice=function(){this.input.bind("selectChoice",function(a,b){if(b.length){var c=$(this).parents(".autocomplete-light-widget").yourlabsWidget();c.selectChoice(b),c.widget.trigger("widgetSelectChoice",[b,c])}})},yourlabs.Widget.prototype.selectChoice=function(a){var b=this.getValue(a);if(!b)return void(window.console&&console.log("yourlabs.Widget.getValue failed"));this.freeDeck(),this.addToDeck(a,b),this.addToSelect(a,b);var c=$(":input:visible").index(this.input);if(this.resetDisplay(),"1"===this.clearInputOnSelectChoice&&(this.input.val(""),this.autocomplete.value=""),this.input.is(":visible"))this.input.focus();else{$(":input:visible:eq("+c+")").focus()}this.select.is("[multiple]")||this.input.prop("disabled",!0)},yourlabs.Widget.prototype.freeDeck=function(){var a=this.maximumValues-this.deck.children().length;if(this.maximumValues&&a<1){var b=$(this.deck.children()[0]);this.deselectChoice(b)}},yourlabs.Widget.prototype.resetDisplay=function(){var a=this.select.find("option:selected").length;this.maximumValues&&a===this.maximumValues?this.input.hide():this.input.show(),this.deck.show(),this.autocomplete.box.is(":visible")&&this.autocomplete.fixPosition()},yourlabs.Widget.prototype.deckChoiceHtml=function(a,b){var c=a.clone();return this.addRemove(c),c},yourlabs.Widget.prototype.optionChoice=function(a){var b=this.choiceTemplate.clone(),c=b.find(".append-option-html");return c.length?c.append(a.html()):b.html(a.html()),b},yourlabs.Widget.prototype.addRemove=function(a){var b=this.widget.find(".remove:last").clone().css("display","inline-block"),c=a.find(".prepend-remove");c.length?c.prepend(b):a.prepend(b)},yourlabs.Widget.prototype.addToDeck=function(a,b){if(!this.deck.find('[data-value="'+b+'"]').length){var c=this.deckChoiceHtml(a);c.attr("data-value",b),this.deck.append(c)}},yourlabs.Widget.prototype.addToSelect=function(a,b){var c=this.select.find('option[value="'+b+'"]');c.length||(this.select.append(''),c=this.select.find('option[value="'+b+'"]')),c.attr("selected","selected"),this.select.trigger("change"),this.updateAutocompleteExclude()},yourlabs.Widget.prototype.deselectChoice=function(a){var b=this.getValue(a);this.select.find('option[value="'+b+'"]').remove(),this.select.trigger("change"),a.remove(),0===this.deck.children().length&&this.deck.hide(),this.updateAutocompleteExclude(),this.resetDisplay(),this.input.prop("disabled",!1),this.widget.trigger("widgetDeselectChoice",[a,this])},yourlabs.Widget.prototype.updateAutocompleteExclude=function(){var a=this,b=this.deck.find(this.autocomplete.choiceSelector);this.autocomplete.data.exclude=$.map(b,function(b){return a.getValue($(b))})},yourlabs.Widget.prototype.initialize=function(){this.initializeAutocomplete();var a=this;this.deck.find(this.autocomplete.choiceSelector).each(function(){var b=a.getValue($(this)),c=a.select.find('option[value="'+b+'"]');c.prop("selected")||c.prop("selected",!0)});var b=this.deck.find(this.input.yourlabsAutocomplete().choiceSelector);this.addRemove(b),this.resetDisplay(),a.select.val()&&!this.select.is("[multiple]")&&this.input.prop("disabled",!0),this.bindSelectChoice()},yourlabs.Widget.prototype.destroy=function(a){a.find("input").unbind("selectChoice").yourlabsAutocomplete("destroy")},$.fn.yourlabsWidget=function(a){a=a||{};var b=this.yourlabsRegistry("widget");if("destroy"===a)return void(b&&(b.destroy(this),this.removeData("widget")));if(void 0===b){b=new yourlabs.Widget(this);for(var c in this.data())if(c&&"widget"===c.substr(0,6)&&"widget"!==c){var d=c.replace("widget","");d=d.charAt(0).toLowerCase()+d.slice(1),b[d]=this.data(c)}b=$.extend(b,a),$(this).yourlabsRegistry("widget",b),b.initialize(),b.widget.attr("data-widget-ready",1),b.widget.trigger("widget-ready")}return b},$(document).ready(function(){$("body").on("initialize",".autocomplete-light-widget[data-widget-bootstrap=normal]",function(){$(this).yourlabsWidget()}),$("body").on("click",".autocomplete-light-widget .deck .remove",function(){var a=$(this).parents(".autocomplete-light-widget").yourlabsWidget(),b=a.input.yourlabsAutocomplete().choiceSelector,c=$(this).parents(b);a.deselectChoice(c)}),$(document).trigger("yourlabsWidgetReady"),$('.autocomplete-light-widget:not([id*="__prefix__"])').each(function(){$(this).trigger("initialize")}),$(document).bind("DOMNodeInserted",function(a){var b;if($(a.target).is("option")){if(b=$(a.target).parents(".autocomplete-light-widget"),!b.length)return;b=b.yourlabsWidget();var c=$(a.target),d=c.attr("value");if(!b.deck.find('[data-value="'+d+'"]').length){var e=b.optionChoice(c);e.attr("data-value",d),b.selectChoice(e)}}else{if(b=$(a.target).find(".autocomplete-light-widget:not([data-widget-ready])"),!b.length)return;if(b.is(".yourlabs-autocomplete"))return;b.yourlabsWidget("destroy"),b.find("input").yourlabsAutocomplete("destroy"),b.trigger("initialize")}});var a=yourlabs.getInternetExplorerVersion();if(-1!==a&&a<9){observe=[".autocomplete-light-widget:not([data-yourlabs-skip])",".autocomplete-light-widget option:not([data-yourlabs-skip])"].join(),$(observe).attr("data-yourlabs-skip",1);var b=function(){$(observe).each(function(){$(document).trigger(jQuery.Event("DOMNodeInserted",{target:$(this)})),$(this).attr("data-yourlabs-skip",1)}),setTimeout(b,500)};setTimeout(b,500)}}); \ No newline at end of file From a5071aa2576ae7117f78290656e32a4fa4d53619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 20 Jan 2018 22:24:13 +0100 Subject: [PATCH 12/22] cof -- Add tests for club views --- gestioncof/tests/test_views.py | 122 +++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..738fe353 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,122 @@ +from django.test import Client, TestCase +from django.urls import reverse + +from gestioncof.models import Club +from gestioncof.tests.testcases import ViewTestCaseMixin + +from .utils import create_user + + +class ClubListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'liste-clubs' + url_expected = '/clubs/liste' + + auth_user = 'member' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + + self.c1 = Club.objects.create(name='Club1') + self.c2 = Club.objects.create(name='Club2') + + m = self.users['member'] + self.c1.membres.add(m) + self.c1.respos.add(m) + + def test_as_member(self): + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertEqual(r.context['owned_clubs'].get(), self.c1) + self.assertEqual(r.context['other_clubs'].get(), self.c2) + + def test_as_staff(self): + u = self.users['staff'] + c = Client() + c.force_login(u) + + r = c.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['owned_clubs'], map(repr, [self.c1, self.c2]), + ordered=False, + ) + + +class ClubMembersViewTests(ViewTestCaseMixin, TestCase): + url_name = 'membres-club' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + @property + def url_kwargs(self): + return {'name': self.c.name} + + @property + def url_expected(self): + return '/clubs/membres/{}'.format(self.c.name) + + def setUp(self): + super().setUp() + + self.u1 = create_user('u1') + self.u2 = create_user('u2') + + self.c = Club.objects.create(name='Club') + self.c.membres.add(self.u1, self.u2) + self.c.respos.add(self.u1) + + def test_as_staff(self): + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertEqual(r.context['members_no_respo'].get(), self.u2) + + def test_as_respo(self): + u = self.users['user'] + self.c.respos.add(u) + + c = Client() + c.force_login(u) + r = c.get(self.url) + + self.assertEqual(r.status_code, 200) + + +class ClubChangeRespoViewTests(ViewTestCaseMixin, TestCase): + url_name = 'change-respo' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + @property + def url_kwargs(self): + return {'club_name': self.c.name, 'user_id': self.users['user'].pk} + + @property + def url_expected(self): + return '/clubs/change_respo/{}/{}'.format( + self.c.name, self.users['user'].pk, + ) + + def setUp(self): + super().setUp() + + self.c = Club.objects.create(name='Club') + + def test(self): + u = self.users['user'] + expected_redirect = reverse('membres-club', kwargs={ + 'name': self.c.name, + }) + self.c.membres.add(u) + + r = self.client.get(self.url) + self.assertRedirects(r, expected_redirect) + self.assertIn(u, self.c.respos.all()) + + self.client.get(self.url) + self.assertNotIn(u, self.c.respos.all()) From bbe46645f75b123989f71c07054091c9df9e3a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 20 Jan 2018 22:24:25 +0100 Subject: [PATCH 13/22] cof -- Fix the club list view --- gestioncof/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gestioncof/views.py b/gestioncof/views.py index 5dfee83f..4e0cb080 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -566,7 +566,7 @@ def liste_clubs(request): if request.user.profile.is_buro: data = {'owned_clubs': clubs.all()} else: - data = {'owned_clubs': request.user.clubs_geres, + data = {'owned_clubs': request.user.clubs_geres.all(), 'other_clubs': clubs.exclude(respos=request.user)} return render(request, 'liste_clubs.html', data) From 7e0ecd8e0f707f912b66cb438778048b877e203e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 21 Jan 2018 17:51:23 +0100 Subject: [PATCH 14/22] Add assertion to check ical data is as expected --- shared/tests/testcases.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/shared/tests/testcases.py b/shared/tests/testcases.py index 15792383..14a5fbfe 100644 --- a/shared/tests/testcases.py +++ b/shared/tests/testcases.py @@ -8,6 +8,8 @@ from django.test import Client from django.utils import timezone from django.utils.functional import cached_property +import icalendar + User = get_user_model() @@ -92,6 +94,40 @@ class TestCaseMixin: else: self.assertEqual(actual, expected) + def _test_event_equal(self, event, exp): + for k, v_desc in exp.items(): + if isinstance(v_desc, tuple): + v_getter = v_desc[0] + v = v_desc[1] + else: + v_getter = lambda v: v + v = v_desc + if v_getter(event[k.upper()]) != v: + return False + return True + + def _find_event(self, ev, l): + for i, elt in enumerate(l): + if self._test_event_equal(ev, elt): + return elt, i + return False, -1 + + def assertCalEqual(self, ical_content, expected): + remaining = expected.copy() + unexpected = [] + + cal = icalendar.Calendar.from_ical(ical_content) + + for ev in cal.walk('vevent'): + found, i_found = self._find_event(ev, remaining) + if found: + remaining.pop(i_found) + else: + unexpected.append(ev) + + self.assertListEqual(unexpected, []) + self.assertListEqual(remaining, []) + class ViewTestCaseMixin(TestCaseMixin): """ From 2e6a54c7dbb8cff5077d35230e063712aa90b3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 21 Jan 2018 17:52:53 +0100 Subject: [PATCH 15/22] cof -- Add tests for calendar views --- gestioncof/tests/test_views.py | 151 +++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..e33fe2f2 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,151 @@ +import uuid +from datetime import timedelta + +from django.contrib import messages +from django.contrib.messages.api import get_messages +from django.contrib.messages.storage.base import Message +from django.test import TestCase + +from bda.models import Salle, Tirage +from gestioncof.models import CalendarSubscription +from gestioncof.tests.testcases import ViewTestCaseMixin + + +class CalendarViewTests(ViewTestCaseMixin, TestCase): + url_name = 'calendar' + url_expected = '/calendar/subscription' + + auth_user = 'member' + auth_forbidden = [None, 'user'] + + post_expected_message = Message( + messages.SUCCESS, "Calendrier mis à jour avec succès.") + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_new(self): + r = self.client.post(self.url, { + 'subscribe_to_events': True, + 'subscribe_to_my_shows': True, + 'other_shows': [], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + cs = self.users['member'].calendarsubscription + self.assertTrue(cs.subscribe_to_events) + self.assertTrue(cs.subscribe_to_my_shows) + + def test_post_edit(self): + u = self.users['member'] + token = uuid.uuid4() + cs = CalendarSubscription.objects.create(token=token, user=u) + + r = self.client.post(self.url, { + 'other_shows': [], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + cs.refresh_from_db() + self.assertEqual(cs.token, token) + self.assertFalse(cs.subscribe_to_events) + self.assertFalse(cs.subscribe_to_my_shows) + + def test_post_other_shows(self): + t = Tirage.objects.create( + ouverture=self.now, + fermeture=self.now, + active=True, + ) + l = Salle.objects.create() + s = t.spectacle_set.create( + date=self.now, price=3.5, slots=20, location=l, listing=True) + + r = self.client.post(self.url, {'other_shows': [str(s.pk)]}) + + self.assertEqual(r.status_code, 200) + + +class CalendarICSViewTests(ViewTestCaseMixin, TestCase): + url_name = 'gestioncof.views.calendar_ics' + + auth_user = None + auth_forbidden = [] + + @property + def url_kwargs(self): + return {'token': self.token} + + @property + def url_expected(self): + return '/calendar/{}/calendar.ics'.format(self.token) + + def setUp(self): + super().setUp() + + self.token = uuid.uuid4() + + self.t = Tirage.objects.create( + ouverture=self.now, + fermeture=self.now, + active=True, + ) + l = Salle.objects.create(name='Location') + self.s1 = self.t.spectacle_set.create( + price=1, slots=10, location=l, listing=True, + title='Spectacle 1', date=self.now + timedelta(days=1), + ) + self.s2 = self.t.spectacle_set.create( + price=2, slots=20, location=l, listing=True, + title='Spectacle 2', date=self.now + timedelta(days=2), + ) + self.s3 = self.t.spectacle_set.create( + price=3, slots=30, location=l, listing=True, + title='Spectacle 3', date=self.now + timedelta(days=3), + ) + + def test(self): + u = self.users['user'] + p = u.participant_set.create(tirage=self.t) + p.attribution_set.create(spectacle=self.s1) + + self.cs = CalendarSubscription.objects.create( + user=u, token=self.token, + subscribe_to_my_shows=True, subscribe_to_events=True, + ) + self.cs.other_shows.add(self.s2) + + r = self.client.get(self.url) + + def get_dt_from_ical(v): + return v.dt + + self.assertCalEqual(r.content.decode('utf-8'), [ + { + 'summary': 'Spectacle 1', + 'dtstart': (get_dt_from_ical, ( + (self.now + timedelta(days=1)).replace(microsecond=0) + )), + 'dtend': (get_dt_from_ical, ( + (self.now + timedelta(days=1, hours=2)).replace( + microsecond=0) + )), + 'location': 'Location', + 'uid': 'show-{}-{}@example.com'.format(self.s1.pk, self.t.pk), + }, + { + 'summary': 'Spectacle 2', + 'dtstart': (get_dt_from_ical, ( + (self.now + timedelta(days=2)).replace(microsecond=0) + )), + 'dtend': (get_dt_from_ical, ( + (self.now + timedelta(days=2, hours=2)).replace( + microsecond=0) + )), + 'location': 'Location', + 'uid': 'show-{}-{}@example.com'.format(self.s2.pk, self.t.pk), + }, + ]) From acf284862a57f1857a2049067d4bb081a797fffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 21 Jan 2018 18:00:56 +0100 Subject: [PATCH 16/22] Users should be able to refuse to subscribe to shows and events --- gestioncof/forms.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gestioncof/forms.py b/gestioncof/forms.py index 2124b7c8..5a25b815 100644 --- a/gestioncof/forms.py +++ b/gestioncof/forms.py @@ -351,10 +351,12 @@ EventFormset = formset_factory(AdminEventForm, BaseEventRegistrationFormset) class CalendarForm(forms.ModelForm): subscribe_to_events = forms.BooleanField( initial=True, - label="Événements du COF") + label="Événements du COF", + required=False) subscribe_to_my_shows = forms.BooleanField( initial=True, - label="Les spectacles pour lesquels j'ai obtenu une place") + label="Les spectacles pour lesquels j'ai obtenu une place", + required=False) other_shows = forms.ModelMultipleChoiceField( label="Spectacles supplémentaires", queryset=Spectacle.objects.filter(tirage__active=True), From 38539a9d53607efd417bee8d4d1d930999e39d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 21 Jan 2018 18:03:01 +0100 Subject: [PATCH 17/22] Name url to export calendar to ical --- gestioncof/templates/gestioncof/calendar_subscription.html | 2 +- gestioncof/tests/test_views.py | 2 +- gestioncof/urls.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/gestioncof/templates/gestioncof/calendar_subscription.html b/gestioncof/templates/gestioncof/calendar_subscription.html index b13cb7f2..4b9e3cbb 100644 --- a/gestioncof/templates/gestioncof/calendar_subscription.html +++ b/gestioncof/templates/gestioncof/calendar_subscription.html @@ -12,7 +12,7 @@ souscrire aux événements du COF et/ou aux spectacles BdA. {% if token %}

Votre calendrier (compatible avec toutes les applications d'agenda) se trouve à -cette adresse.

+cette adresse.

  • Pour l'ajouter à Thunderbird (lightning), il faut copier ce lien et aller diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py index e33fe2f2..06d6be4a 100644 --- a/gestioncof/tests/test_views.py +++ b/gestioncof/tests/test_views.py @@ -70,7 +70,7 @@ class CalendarViewTests(ViewTestCaseMixin, TestCase): class CalendarICSViewTests(ViewTestCaseMixin, TestCase): - url_name = 'gestioncof.views.calendar_ics' + url_name = 'calendar.ics' auth_user = None auth_forbidden = [] diff --git a/gestioncof/urls.py b/gestioncof/urls.py index 2be609b3..dde543a5 100644 --- a/gestioncof/urls.py +++ b/gestioncof/urls.py @@ -52,7 +52,8 @@ events_patterns = [ calendar_patterns = [ url(r'^subscription$', views.calendar, name='calendar'), - url(r'^(?P[a-z0-9-]+)/calendar.ics$', views.calendar_ics) + url(r'^(?P[a-z0-9-]+)/calendar.ics$', views.calendar_ics, + name='calendar.ics'), ] clubs_patterns = [ From ac1a57d96993466d38a2e87edcbcd1e5501b8c54 Mon Sep 17 00:00:00 2001 From: Basile Clement Date: Sun, 11 Feb 2018 17:01:26 +0100 Subject: [PATCH 18/22] Make provisioning script stop immediately on errors By default, bash will ignore any failing commands and happily proceed to execute the next ones. This is usually not the behavior the we want in provisioning script (or ever in scripts, actually): if one step of the provisioning fails, it doesn't make much sense to proceed with the following ones. This simple patch uses `set -e` to ask bash to abort the whole script if any command within it fails, leading to outputs that are easier to parse since the commands following a failing one will usually fail also, hiding the root cause. --- provisioning/bootstrap.sh | 3 +++ provisioning/prepare_django.sh | 3 +++ 2 files changed, 6 insertions(+) diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index 69bbcf4c..cb6917a7 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -1,5 +1,8 @@ #!/bin/sh +# Stop if an error is encountered +set -e + # Configuration de la base de données. Le mot de passe est constant car c'est # pour une installation de dév locale qui ne sera accessible que depuis la # machine virtuelle. diff --git a/provisioning/prepare_django.sh b/provisioning/prepare_django.sh index 1818a0cd..891108e8 100644 --- a/provisioning/prepare_django.sh +++ b/provisioning/prepare_django.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Stop if an error is encountered. +set -e + python manage.py migrate python manage.py loaddata gestion sites articles python manage.py loaddevdata From 3314670cab2917f3d57e3340c56a7a5daddd92c8 Mon Sep 17 00:00:00 2001 From: Basile Clement Date: Sun, 11 Feb 2018 19:09:07 +0100 Subject: [PATCH 19/22] Various fixes for Django 1.11 - The {% cycle %} command was used non-quoted arguments separated by commas, while it is supposed to use quoted arguments separated by spaces (I'm actually not sure how that ever worked :) - django-bootstrap-form was at version 3.2.1 which is not compatible with Django 1.11 (but also required by GestioCOF). I upgraded it to version 3.3. --- bda/templates/bda/inscription-formset.html | 2 +- gestioncof/templates/inscription-petit-cours-formset.html | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bda/templates/bda/inscription-formset.html b/bda/templates/bda/inscription-formset.html index 65ef389b..88b65600 100644 --- a/bda/templates/bda/inscription-formset.html +++ b/bda/templates/bda/inscription-formset.html @@ -14,7 +14,7 @@ {% endif %} - + {% for field in form.visible_fields %} {% if field.name != "DELETE" and field.name != "priority" %} diff --git a/gestioncof/templates/inscription-petit-cours-formset.html b/gestioncof/templates/inscription-petit-cours-formset.html index ec8979f5..40311772 100644 --- a/gestioncof/templates/inscription-petit-cours-formset.html +++ b/gestioncof/templates/inscription-petit-cours-formset.html @@ -16,7 +16,7 @@ {% endif %} - + {% for field in form.visible_fields %} {% if field.name != "DELETE" and field.name != "priority" %} diff --git a/requirements.txt b/requirements.txt index d1046042..b30660ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ psycopg2 Pillow six unicodecsv -django-bootstrap-form==3.2.1 +django-bootstrap-form==3.3 asgiref==1.1.1 daphne==1.3.0 asgi-redis==1.3.0 From 6ecc9a54b36d153dea502057531dd5b6bb7ea8cc Mon Sep 17 00:00:00 2001 From: Basile Clement Date: Sun, 11 Feb 2018 19:24:01 +0100 Subject: [PATCH 20/22] Properly propagate the default number of places in tirage Fixes #182. --- bda/templates/bda/inscription-tirage.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bda/templates/bda/inscription-tirage.html b/bda/templates/bda/inscription-tirage.html index d56b4229..3fd81378 100644 --- a/bda/templates/bda/inscription-tirage.html +++ b/bda/templates/bda/inscription-tirage.html @@ -27,6 +27,14 @@ var django = { var newFor = $(this).attr('for').replace('-' + (total-1) + '-','-' + total + '-'); $(this).attr('for', newFor); }); + // Cloning