diff --git a/kfet/templatetags/kfet_tags.py b/kfet/templatetags/kfet_tags.py
index 7fa9d7c7..68b74738 100644
--- a/kfet/templatetags/kfet_tags.py
+++ b/kfet/templatetags/kfet_tags.py
@@ -1,15 +1,16 @@
-# -*- coding: utf-8 -*-
+import re
from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe
-from math import floor
-import re
-from kfet.config import kfet_config
+from ..utils import to_ukf
+
register = template.Library()
+register.filter('ukf', to_ukf)
+
@register.filter()
def highlight_text(text, q):
@@ -28,7 +29,6 @@ def highlight_user(user, q):
return highlight_text(text, q)
-
@register.filter(is_safe=True)
def highlight_clipper(clipper, q):
if clipper.fullname:
@@ -38,8 +38,14 @@ def highlight_clipper(clipper, q):
return highlight_text(text, q)
+@register.filter()
+def widget_type(field):
+ return field.field.widget.__class__.__name__
+
@register.filter()
-def ukf(balance, is_cof):
- grant = is_cof and (1 + kfet_config.subvention_cof / 100) or 1
- return floor(balance * 10 * grant)
+def slice(l, start, end=None):
+ if end is None:
+ end = start
+ start = 0
+ return l[start:end]
diff --git a/kfet/tests/test_config.py b/kfet/tests/test_config.py
index 03c9cf3c..43497ca8 100644
--- a/kfet/tests/test_config.py
+++ b/kfet/tests/test_config.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
from decimal import Decimal
from django.test import TestCase
diff --git a/kfet/tests/test_forms.py b/kfet/tests/test_forms.py
index 7f129a3f..e946d39d 100644
--- a/kfet/tests/test_forms.py
+++ b/kfet/tests/test_forms.py
@@ -1,56 +1,48 @@
-# -*- coding: utf-8 -*-
+import datetime
+from unittest import mock
from django.test import TestCase
-from django.contrib.auth.models import User, Group
+from django.utils import timezone
-from kfet.forms import UserGroupForm
+from kfet.forms import KPsulCheckoutForm
+from kfet.models import Checkout
+
+from .utils import create_user
-class UserGroupFormTests(TestCase):
- """Test suite for UserGroupForm."""
+class KPsulCheckoutFormTests(TestCase):
def setUp(self):
- # create user
- self.user = User.objects.create(username="foo", password="foo")
+ self.now = timezone.now()
- # create some K-Fêt groups
- prefix_name = "K-Fêt "
- names = ["Group 1", "Group 2", "Group 3"]
- self.kfet_groups = [
- Group.objects.create(name=prefix_name+name)
- for name in names
- ]
+ user = create_user()
- # create a non-K-Fêt group
- self.other_group = Group.objects.create(name="Other group")
-
- def test_choices(self):
- """Only K-Fêt groups are selectable."""
- form = UserGroupForm(instance=self.user)
- groups_field = form.fields['groups']
- self.assertQuerysetEqual(
- groups_field.queryset,
- [repr(g) for g in self.kfet_groups],
- ordered=False,
+ 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),
)
- def test_keep_others(self):
- """User stays in its non-K-Fêt groups."""
- user = self.user
+ self.form = KPsulCheckoutForm()
- # add user to a non-K-Fêt group
- user.groups.add(self.other_group)
+ def test_checkout(self):
+ checkout_f = self.form.fields['checkout']
+ self.assertListEqual(list(checkout_f.choices), [
+ ('', '---------'),
+ (self.c1.pk, 'C1'),
+ ])
- # add user to some K-Fêt groups through UserGroupForm
- data = {
- 'groups': [group.pk for group in self.kfet_groups],
- }
- form = UserGroupForm(data, instance=user)
+ @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.is_valid()
- form.save()
- self.assertQuerysetEqual(
- user.groups.all(),
- [repr(g) for g in [self.other_group] + self.kfet_groups],
- ordered=False,
- )
+ form = KPsulCheckoutForm()
+
+ checkout_f = form.fields['checkout']
+ self.assertListEqual(list(checkout_f.choices), [('', '---------')])
diff --git a/kfet/tests/test_models.py b/kfet/tests/test_models.py
new file mode 100644
index 00000000..727cac4e
--- /dev/null
+++ b/kfet/tests/test_models.py
@@ -0,0 +1,60 @@
+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, Checkout
+
+from .utils import create_user
+
+User = get_user_model()
+
+
+class AccountTests(TestCase):
+
+ def setUp(self):
+ self.account = Account(trigramme='000')
+ self.account.save({'username': 'user'})
+
+ def test_password(self):
+ self.account.change_pwd('anna')
+ self.account.save()
+
+ self.assertEqual(Account.objects.get_by_password('anna'), self.account)
+
+ with self.assertRaises(Account.DoesNotExist):
+ Account.objects.get_by_password(None)
+
+ 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_statistic.py b/kfet/tests/test_statistic.py
index 4fb0785d..93de27a0 100644
--- a/kfet/tests/test_statistic.py
+++ b/kfet/tests/test_statistic.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
from unittest.mock import patch
from django.test import TestCase, Client
@@ -9,7 +7,8 @@ from kfet.models import Account, Article, ArticleCategory
class TestStats(TestCase):
- @patch('kfet.signals.messages')
+
+ @patch('gestioncof.signals.messages')
def test_user_stats(self, mock_messages):
"""
Checks that we can get the stat-related pages without any problem.
diff --git a/kfet/tests/test_tests_utils.py b/kfet/tests/test_tests_utils.py
new file mode 100644
index 00000000..8308bd5b
--- /dev/null
+++ b/kfet/tests/test_tests_utils.py
@@ -0,0 +1,95 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Permission
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+
+from gestioncof.models import CofProfile
+
+from ..models import Account
+from .testcases import TestCaseMixin
+from .utils import (
+ create_user, create_team, create_root, get_perms, user_add_perms,
+)
+
+
+User = get_user_model()
+
+
+class UserHelpersTests(TestCaseMixin, TestCase):
+
+ def test_create_user(self):
+ """create_user creates a basic user and its account."""
+ u = create_user()
+ a = u.profile.account_kfet
+
+ self.assertInstanceExpected(u, {
+ 'get_full_name': 'first last',
+ 'username': 'user',
+ })
+ self.assertFalse(u.user_permissions.exists())
+
+ self.assertEqual('000', a.trigramme)
+
+ def test_create_team(self):
+ u = create_team()
+ a = u.profile.account_kfet
+
+ self.assertInstanceExpected(u, {
+ 'get_full_name': 'team member',
+ 'username': 'team',
+ })
+ self.assertTrue(u.has_perm('kfet.is_team'))
+
+ self.assertEqual('100', a.trigramme)
+
+ def test_create_root(self):
+ u = create_root()
+ a = u.profile.account_kfet
+
+ self.assertInstanceExpected(u, {
+ 'get_full_name': 'super user',
+ 'username': 'root',
+ 'is_superuser': True,
+ 'is_staff': True,
+ })
+
+ self.assertEqual('200', a.trigramme)
+
+
+class PermHelpersTest(TestCaseMixin, TestCase):
+
+ def setUp(self):
+ cts = ContentType.objects.get_for_models(Account, CofProfile)
+ self.perm1 = Permission.objects.create(
+ content_type=cts[Account],
+ codename='test_perm',
+ name='Perm for test',
+ )
+ self.perm2 = Permission.objects.create(
+ content_type=cts[CofProfile],
+ codename='another_test_perm',
+ name='Another one',
+ )
+ self.perm_team = Permission.objects.get(
+ content_type__app_label='kfet',
+ codename='is_team',
+ )
+
+ def test_get_perms(self):
+ perms = get_perms('kfet.test_perm', 'gestioncof.another_test_perm')
+ self.assertDictEqual(perms, {
+ 'kfet.test_perm': self.perm1,
+ 'gestioncof.another_test_perm': self.perm2,
+ })
+
+ def test_user_add_perms(self):
+ user = User.objects.create_user(username='user', password='user')
+ user.user_permissions.add(self.perm1)
+
+ user_add_perms(user, ['kfet.is_team', 'gestioncof.another_test_perm'])
+
+ self.assertQuerysetEqual(
+ user.user_permissions.all(),
+ map(repr, [self.perm1, self.perm2, self.perm_team]),
+ ordered=False,
+ )
diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py
new file mode 100644
index 00000000..40f895a1
--- /dev/null
+++ b/kfet/tests/test_views.py
@@ -0,0 +1,2240 @@
+import json
+from datetime import datetime, timedelta
+from decimal import Decimal
+from unittest import mock
+
+from django.contrib.auth.models import Group
+from django.core.urlresolvers import reverse
+from django.test import Client, TestCase
+from django.utils import timezone
+
+from ..config import kfet_config
+from ..models import (
+ Account, Article, ArticleCategory, Checkout, CheckoutStatement, Inventory,
+ InventoryArticle, Operation, OperationGroup, Order, OrderArticle, Supplier,
+ SupplierArticle, Transfer, TransferGroup,
+)
+from .testcases import ViewTestCaseMixin
+from .utils import create_team, create_user, get_perms
+
+
+class AccountListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account'
+ url_expected = '/k-fet/accounts/'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class AccountValidFreeTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.is_validandfree.ajax'
+ url_expected = '/k-fet/accounts/is_validandfree'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok_isvalid_isfree(self):
+ """Upper case trigramme not taken is valid and free."""
+ r = self.client.get(self.url, {'trigramme': 'AAA'})
+ self.assertDictEqual(json.loads(r.content.decode('utf-8')), {
+ 'is_valid': True,
+ 'is_free': True,
+ })
+
+ def test_ok_isvalid_notfree(self):
+ """Already taken trigramme is not free, but valid."""
+ r = self.client.get(self.url, {'trigramme': '000'})
+ self.assertDictEqual(json.loads(r.content.decode('utf-8')), {
+ 'is_valid': True,
+ 'is_free': False,
+ })
+
+ def test_ok_notvalid_isfree(self):
+ """Lower case if forbidden but free."""
+ r = self.client.get(self.url, {'trigramme': 'aaa'})
+ self.assertDictEqual(json.loads(r.content.decode('utf-8')), {
+ 'is_valid': False,
+ 'is_free': True,
+ })
+
+
+class AccountCreateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.create'
+ url_expected = '/k-fet/accounts/new'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ post_data = {
+ 'trigramme': 'AAA',
+ 'username': 'plopplopplop',
+ 'first_name': 'first',
+ 'last_name': 'last',
+ 'email': 'email@domain.net',
+ }
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=['kfet.add_account']),
+ }
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertRedirects(r, reverse('kfet.account.create'))
+
+ account = Account.objects.get(trigramme='AAA')
+
+ self.assertInstanceExpected(account, {
+ 'username': 'plopplopplop',
+ 'first_name': 'first',
+ 'last_name': 'last',
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class AccountCreateAjaxViewTests(ViewTestCaseMixin, TestCase):
+ urls_conf = [{
+ 'name': 'kfet.account.create.fromuser',
+ 'kwargs': {'username': 'user'},
+ 'expected': '/k-fet/accounts/new/user/user',
+ }, {
+ 'name': 'kfet.account.create.fromclipper',
+ 'kwargs': {
+ 'login_clipper': 'myclipper',
+ 'fullname': 'first last1 last2',
+ },
+ 'expected': (
+ '/k-fet/accounts/new/clipper/myclipper/first%20last1%20last2'
+ ),
+ }, {
+ 'name': 'kfet.account.create.empty',
+ 'expected': '/k-fet/accounts/new/empty',
+ }]
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_fromuser(self):
+ r = self.client.get(self.t_urls[0])
+ self.assertEqual(r.status_code, 200)
+
+ user = self.users['user']
+
+ self.assertEqual(r.context['user_form'].instance, user)
+ self.assertEqual(r.context['cof_form'].instance, user.profile)
+ self.assertIn('account_form', r.context)
+
+ def test_fromclipper(self):
+ r = self.client.get(self.t_urls[1])
+ self.assertEqual(r.status_code, 200)
+
+ self.assertIn('user_form', r.context)
+ self.assertIn('cof_form', r.context)
+ self.assertIn('account_form', r.context)
+
+ def test_empty(self):
+ r = self.client.get(self.t_urls[2])
+ self.assertEqual(r.status_code, 200)
+
+ self.assertIn('user_form', r.context)
+ self.assertIn('cof_form', r.context)
+ self.assertIn('account_form', r.context)
+
+
+class AccountCreateAutocompleteViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.create.autocomplete'
+ url_expected = '/k-fet/autocomplete/account_new'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ r = self.client.get(self.url, {'q': 'first'})
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(len(r.context['users_notcof']), 0)
+ self.assertEqual(len(r.context['users_cof']), 0)
+ self.assertSetEqual(set(r.context['kfet']), set([
+ (self.accounts['user'], self.users['user']),
+ ]))
+
+
+class AccountSearchViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.search.autocomplete'
+ url_expected = '/k-fet/autocomplete/account_search'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ r = self.client.get(self.url, {'q': 'first'})
+ self.assertEqual(r.status_code, 200)
+ self.assertSetEqual(set(r.context['accounts']), set([
+ ('000', 'first last'),
+ ]))
+
+
+class AccountReadViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.read'
+ url_kwargs = {'trigramme': '001'}
+ url_expected = '/k-fet/accounts/001'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def get_users_extra(self):
+ return {
+ 'user1': create_user('user1', '001'),
+ }
+
+ def setUp(self):
+ super().setUp()
+
+ user1_acc = self.accounts['user1']
+ team_acc = self.accounts['team']
+
+ # Dummy operations and operation groups
+ checkout = Checkout.objects.create(
+ created_by=team_acc, name="checkout",
+ valid_from=timezone.now(),
+ valid_to=timezone.now() + timezone.timedelta(days=365)
+ )
+ opeg_data = [
+ (timezone.now(), Decimal('10')),
+ (timezone.now() - timezone.timedelta(days=3), Decimal('3')),
+ ]
+ OperationGroup.objects.bulk_create([
+ OperationGroup(
+ on_acc=user1_acc, checkout=checkout, at=at, is_cof=False,
+ amount=amount
+ )
+ for (at, amount) in opeg_data
+ ])
+ self.operation_groups = OperationGroup.objects.order_by("-amount")
+ Operation.objects.create(
+ group=self.operation_groups[0],
+ type=Operation.PURCHASE,
+ amount=Decimal('10')
+ )
+ Operation.objects.create(
+ group=self.operation_groups[1],
+ type=Operation.PURCHASE,
+ amount=Decimal('3')
+ )
+
+ def test_ok(self):
+ """We can query the "Account - Read" page."""
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_ok_self(self):
+ client = Client()
+ client.login(username='user1', password='user1')
+ r = client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class AccountUpdateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.update'
+ url_kwargs = {'trigramme': '001'}
+ url_expected = '/k-fet/accounts/001/edit'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ post_data = {
+ # User
+ 'first_name': 'The first',
+ 'last_name': 'The last',
+ 'email': '',
+ # Group
+ 'groups[]': [],
+ # Account
+ 'trigramme': '051',
+ 'nickname': '',
+ 'promo': '',
+ # 'is_frozen': not checked
+ # Account password
+ 'pwd1': '',
+ 'pwd2': '',
+ }
+
+ def get_users_extra(self):
+ return {
+ 'user1': create_user('user1', '001'),
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.change_account',
+ ]),
+ }
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_get_ok_self(self):
+ client = Client()
+ client.login(username='user1', password='user1')
+ r = client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertRedirects(r, reverse('kfet.account.read', args=['051']))
+
+ self.accounts['user1'].refresh_from_db()
+ self.users['user1'].refresh_from_db()
+
+ self.assertInstanceExpected(self.accounts['user1'], {
+ 'first_name': 'The first',
+ 'last_name': 'The last',
+ 'trigramme': '051',
+ })
+
+ def test_post_ok_self(self):
+ client = Client()
+ client.login(username='user1', password='user1')
+
+ post_data = {
+ 'first_name': 'The first',
+ 'last_name': 'The last',
+ }
+
+ r = client.post(self.url, post_data)
+ self.assertRedirects(r, reverse('kfet.account.read', args=['001']))
+
+ self.accounts['user1'].refresh_from_db()
+ self.users['user1'].refresh_from_db()
+
+ self.assertInstanceExpected(self.accounts['user1'], {
+ 'first_name': 'The first',
+ 'last_name': 'The last',
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class AccountGroupListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.group'
+ url_expected = '/k-fet/accounts/groups'
+
+ auth_user = 'team1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=['kfet.manage_perms']),
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.group1 = Group.objects.create(name='K-Fêt - Group1')
+ self.group2 = Group.objects.create(name='K-Fêt - Group2')
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ self.assertQuerysetEqual(
+ r.context['groups'],
+ map(repr, [self.group1, self.group2]),
+ ordered=False,
+ )
+
+
+class AccountGroupCreateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.group.create'
+ url_expected = '/k-fet/accounts/groups/new'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=['kfet.manage_perms']),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ 'name': 'The Group',
+ 'permissions': [
+ str(self.perms['kfet.is_team'].pk),
+ str(self.perms['kfet.manage_perms'].pk),
+ ],
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.perms = get_perms(
+ 'kfet.is_team',
+ 'kfet.manage_perms',
+ )
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertRedirects(r, reverse('kfet.account.group'))
+
+ group = Group.objects.get(name='K-Fêt The Group')
+
+ self.assertQuerysetEqual(
+ group.permissions.all(),
+ map(repr, [
+ self.perms['kfet.is_team'],
+ self.perms['kfet.manage_perms'],
+ ]),
+ ordered=False,
+ )
+
+
+class AccountGroupUpdateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.group.update'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team1'
+ auth_forbidden = [None, 'user', 'team']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.group.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/accounts/groups/{}/edit'.format(self.group.pk)
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=['kfet.manage_perms']),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ 'name': 'The Group',
+ 'permissions': [
+ str(self.perms['kfet.is_team'].pk),
+ str(self.perms['kfet.manage_perms'].pk),
+ ],
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.perms = get_perms(
+ 'kfet.is_team',
+ 'kfet.manage_perms',
+ )
+ self.group = Group.objects.create(name='K-Fêt - Group')
+ self.group.permissions.set(self.perms.values())
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertRedirects(r, reverse('kfet.account.group'))
+
+ self.group.refresh_from_db()
+
+ self.assertEqual(self.group.name, 'K-Fêt The Group')
+ self.assertQuerysetEqual(
+ self.group.permissions.all(),
+ map(repr, [
+ self.perms['kfet.is_team'],
+ self.perms['kfet.manage_perms'],
+ ]),
+ ordered=False,
+ )
+
+
+class AccountNegativeListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.negative'
+ url_expected = '/k-fet/accounts/negatives'
+
+ auth_user = 'team1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=['kfet.view_negs']),
+ }
+
+ def setUp(self):
+ super().setUp()
+ account = self.accounts['user']
+ account.balance = -5
+ account.save()
+ account.update_negative()
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+ self.assertQuerysetEqual(
+ r.context['negatives'],
+ map(repr, [self.accounts['user'].negative]),
+ ordered=False,
+ )
+
+
+class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.stat.operation.list'
+ url_kwargs = {'trigramme': '001'}
+ url_expected = '/k-fet/accounts/001/stat/operations/list'
+
+ auth_user = 'user1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {'user1': create_user('user1', '001')}
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ content = json.loads(r.content.decode('utf-8'))
+
+ base_url = reverse('kfet.account.stat.operation', args=['001'])
+
+ expected_stats = [{
+ 'label': 'Derniers mois',
+ 'url': {
+ 'path': base_url,
+ 'query': {
+ 'scale_n_steps': ['7'],
+ 'scale_name': ['month'],
+ 'types': ["['purchase']"],
+ 'scale_last': ['True'],
+ },
+ },
+ }, {
+ 'label': 'Dernières semaines',
+ 'url': {
+ 'path': base_url,
+ 'query': {
+ 'scale_n_steps': ['7'],
+ 'scale_name': ['week'],
+ 'types': ["['purchase']"],
+ 'scale_last': ['True'],
+ },
+ },
+ }, {
+ 'label': 'Derniers jours',
+ 'url': {
+ 'path': base_url,
+ 'query': {
+ 'scale_n_steps': ['7'],
+ 'scale_name': ['day'],
+ 'types': ["['purchase']"],
+ 'scale_last': ['True'],
+ },
+ },
+ }]
+
+ for stat, expected in zip(content['stats'], expected_stats):
+ expected_url = expected.pop('url')
+ self.assertUrlsEqual(stat['url'], expected_url)
+ self.assertDictContainsSubset(expected, stat)
+
+
+class AccountStatOperationViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.stat.operation'
+ url_kwargs = {'trigramme': '001'}
+ url_expected = '/k-fet/accounts/001/stat/operations'
+
+ auth_user = 'user1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {'user1': create_user('user1', '001')}
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class AccountStatBalanceListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.stat.balance.list'
+ url_kwargs = {'trigramme': '001'}
+ url_expected = '/k-fet/accounts/001/stat/balance/list'
+
+ auth_user = 'user1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {'user1': create_user('user1', '001')}
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ content = json.loads(r.content.decode('utf-8'))
+
+ base_url = reverse('kfet.account.stat.balance', args=['001'])
+
+ expected_stats = [{
+ 'label': 'Tout le temps',
+ 'url': base_url,
+ }, {
+ 'label': '1 an',
+ 'url': {
+ 'path': base_url,
+ 'query': {'last_days': ['365']},
+ },
+ }, {
+ 'label': '6 mois',
+ 'url': {
+ 'path': base_url,
+ 'query': {'last_days': ['183']},
+ },
+ }, {
+ 'label': '3 mois',
+ 'url': {
+ 'path': base_url,
+ 'query': {'last_days': ['90']},
+ },
+ }, {
+ 'label': '30 jours',
+ 'url': {
+ 'path': base_url,
+ 'query': {'last_days': ['30']},
+ },
+ }]
+
+ for stat, expected in zip(content['stats'], expected_stats):
+ expected_url = expected.pop('url')
+ self.assertUrlsEqual(stat['url'], expected_url)
+ self.assertDictContainsSubset(expected, stat)
+
+
+class AccountStatBalanceViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.stat.balance'
+ url_kwargs = {'trigramme': '001'}
+ url_expected = '/k-fet/accounts/001/stat/balance'
+
+ auth_user = 'user1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {'user1': create_user('user1', '001')}
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class CheckoutListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.checkout'
+ url_expected = '/k-fet/checkouts/'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def setUp(self):
+ super().setUp()
+ self.checkout1 = Checkout.objects.create(
+ name='Checkout 1',
+ created_by=self.accounts['team'],
+ valid_from=self.now,
+ valid_to=self.now + timedelta(days=5),
+ )
+ self.checkout2 = Checkout.objects.create(
+ name='Checkout 2',
+ created_by=self.accounts['team'],
+ valid_from=self.now + timedelta(days=10),
+ valid_to=self.now + timedelta(days=15),
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+ self.assertQuerysetEqual(
+ r.context['checkouts'],
+ map(repr, [self.checkout1, self.checkout2]),
+ ordered=False,
+ )
+
+
+class CheckoutCreateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.checkout.create'
+ url_expected = '/k-fet/checkouts/new'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ post_data = {
+ 'name': 'Checkout',
+ 'valid_from': '2017-10-08 17:45:00',
+ 'valid_to': '2017-11-08 16:00:00',
+ 'balance': '3.14',
+ # 'is_protected': not checked
+ }
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=['kfet.add_checkout']),
+ }
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+
+ checkout = Checkout.objects.get(name='Checkout')
+ self.assertRedirects(r, checkout.get_absolute_url())
+
+ self.assertInstanceExpected(checkout, {
+ 'name': 'Checkout',
+ 'valid_from': timezone.make_aware(datetime(2017, 10, 8, 17, 45)),
+ 'valid_to': timezone.make_aware(datetime(2017, 11, 8, 16, 00)),
+ 'balance': Decimal('3.14'),
+ 'is_protected': False,
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class CheckoutReadViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.checkout.read'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.checkout.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/checkouts/{}'.format(self.checkout.pk)
+
+ def setUp(self):
+ super().setUp()
+
+ 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)
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.context['checkout'], self.checkout)
+
+
+class CheckoutUpdateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.checkout.update'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ post_data = {
+ 'name': 'Checkout updated',
+ 'valid_from': '2018-01-01 08:00:00',
+ 'valid_to': '2018-07-01 16:00:00',
+ }
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.checkout.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/checkouts/{}/edit'.format(self.checkout.pk)
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.change_checkout',
+ ]),
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.checkout = Checkout.objects.create(
+ name='Checkout',
+ valid_from=self.now,
+ valid_to=self.now + timedelta(days=5),
+ balance=Decimal('3.14'),
+ is_protected=False,
+ created_by=self.accounts['team'],
+ )
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertRedirects(r, self.checkout.get_absolute_url())
+
+ self.checkout.refresh_from_db()
+
+ self.assertInstanceExpected(self.checkout, {
+ 'name': 'Checkout updated',
+ 'valid_from': timezone.make_aware(datetime(2018, 1, 1, 8, 0, 0)),
+ 'valid_to': timezone.make_aware(datetime(2018, 7, 1, 16, 0, 0)),
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class CheckoutStatementListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.checkoutstatement'
+ url_expected = '/k-fet/checkouts/statements/'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def setUp(self):
+ super().setUp()
+ self.checkout1 = Checkout.objects.create(
+ created_by=self.accounts['team'],
+ name='Checkout 1',
+ valid_from=self.now,
+ valid_to=self.now + timedelta(days=5),
+ )
+ self.checkout2 = Checkout.objects.create(
+ created_by=self.accounts['team'],
+ name='Checkout 2',
+ valid_from=self.now + timedelta(days=10),
+ valid_to=self.now + timedelta(days=15),
+ )
+ self.statement1 = CheckoutStatement.objects.create(
+ checkout=self.checkout1,
+ by=self.accounts['team'],
+ balance_old=5,
+ balance_new=0,
+ amount_taken=5,
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ expected_statements = (
+ list(self.checkout1.statements.all()) +
+ list(self.checkout2.statements.all())
+ )
+
+ self.assertQuerysetEqual(
+ r.context['checkoutstatements'],
+ map(repr, expected_statements),
+ ordered=False,
+ )
+
+
+class CheckoutStatementCreateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.checkoutstatement.create'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ post_data = {
+ # Let
+ 'balance_001': 0, 'balance_002': 0, 'balance_005': 0,
+ 'balance_01': 0, 'balance_02': 0, 'balance_05': 0,
+ 'balance_1': 1, 'balance_2': 0, 'balance_5': 0,
+ 'balance_10': 1, 'balance_20': 0, 'balance_50': 0,
+ 'balance_100': 1, 'balance_200': 0, 'balance_500': 0,
+ # Taken
+ 'taken_001': 0, 'taken_002': 0, 'taken_005': 0,
+ 'taken_01': 0, 'taken_02': 0, 'taken_05': 0,
+ 'taken_1': 2, 'taken_2': 0, 'taken_5': 0,
+ 'taken_10': 2, 'taken_20': 0, 'taken_50': 0,
+ 'taken_100': 2, 'taken_200': 0, 'taken_500': 0,
+ 'taken_cheque': 0,
+ # 'not_count': not checked
+ }
+
+ @property
+ def url_kwargs(self):
+ return {'pk_checkout': self.checkout.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/checkouts/{}/statements/add'.format(self.checkout.pk)
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '001', perms=[
+ 'kfet.add_checkoutstatement',
+ ]),
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.checkout = Checkout.objects.create(
+ name='Checkout',
+ created_by=self.accounts['team'],
+ balance=5,
+ valid_from=self.now,
+ valid_to=self.now + timedelta(days=5),
+ )
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ @mock.patch('django.utils.timezone.now')
+ def test_post_ok(self, mock_now):
+ self.now += timedelta(days=2)
+ mock_now.return_value = self.now
+
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertRedirects(r, self.checkout.get_absolute_url())
+
+ statement = CheckoutStatement.objects.get(at=self.now)
+
+ self.assertInstanceExpected(statement, {
+ 'by': self.accounts['team1'],
+ 'checkout': self.checkout,
+ 'balance_old': Decimal('5'),
+ 'balance_new': Decimal('111'),
+ 'amount_taken': Decimal('222'),
+ 'amount_error': Decimal('328'),
+ 'at': self.now,
+ 'not_count': False,
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class CheckoutStatementUpdateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.checkoutstatement.update'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ post_data = {
+ 'amount_taken': 3,
+ 'amount_error': 2,
+ 'balance_old': 8,
+ 'balance_new': 5,
+ # Taken
+ 'taken_001': 0, 'taken_002': 0, 'taken_005': 0,
+ 'taken_01': 0, 'taken_02': 0, 'taken_05': 0,
+ 'taken_1': 1, 'taken_2': 1, 'taken_5': 0,
+ 'taken_10': 0, 'taken_20': 0, 'taken_50': 0,
+ 'taken_100': 0, 'taken_200': 0, 'taken_500': 0,
+ 'taken_cheque': 0,
+ }
+
+ @property
+ def url_kwargs(self):
+ return {
+ 'pk_checkout': self.checkout.pk,
+ 'pk': self.statement.pk,
+ }
+
+ @property
+ def url_expected(self):
+ return '/k-fet/checkouts/{pk_checkout}/statements/{pk}/edit'.format(
+ pk_checkout=self.checkout.pk,
+ pk=self.statement.pk,
+ )
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.change_checkoutstatement',
+ ]),
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.checkout = Checkout.objects.create(
+ name='Checkout',
+ created_by=self.accounts['team'],
+ balance=5,
+ valid_from=self.now,
+ valid_to=self.now + timedelta(days=5),
+ )
+ self.statement = CheckoutStatement.objects.create(
+ by=self.accounts['team'],
+ checkout=self.checkout,
+ balance_new=5,
+ balance_old=8,
+ amount_error=2,
+ amount_taken=5,
+ )
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ @mock.patch('django.utils.timezone.now')
+ def test_post_ok(self, mock_now):
+ self.now += timedelta(days=2)
+ mock_now.return_value = self.now
+
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertRedirects(r, self.checkout.get_absolute_url())
+
+ self.statement.refresh_from_db()
+
+ self.assertInstanceExpected(self.statement, {
+ 'taken_1': 1,
+ 'taken_2': 1,
+ 'balance_new': 5,
+ 'balance_old': 8,
+ 'amount_error': 0,
+ 'amount_taken': 3,
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class ArticleCategoryListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.category'
+ url_expected = '/k-fet/categories/'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def setUp(self):
+ super().setUp()
+ self.category1 = ArticleCategory.objects.create(name='Category 1')
+ self.category2 = ArticleCategory.objects.create(name='Category 2')
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ self.assertQuerysetEqual(
+ r.context['categories'],
+ map(repr, [self.category1, self.category2]),
+ )
+
+
+class ArticleCategoryUpdateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.category.update'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.category.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/categories/{}/edit'.format(self.category.pk)
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.change_articlecategory',
+ ]),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ 'name': 'The Category',
+ # 'has_addcost': not checked
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.category = ArticleCategory.objects.create(name='Category')
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertRedirects(r, reverse('kfet.category'))
+
+ self.category.refresh_from_db()
+
+ self.assertInstanceExpected(self.category, {
+ 'name': 'The Category',
+ 'has_addcost': False,
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class ArticleListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.article'
+ url_expected = '/k-fet/articles/'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def setUp(self):
+ super().setUp()
+ category = ArticleCategory.objects.create(name='Category')
+ self.article1 = Article.objects.create(
+ name='Article 1',
+ category=category,
+ )
+ self.article2 = Article.objects.create(
+ name='Article 2',
+ category=category,
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+ self.assertQuerysetEqual(
+ r.context['articles'],
+ map(repr, [self.article1, self.article2]),
+ )
+
+
+class ArticleCreateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.article.create'
+ url_expected = '/k-fet/articles/new'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=['kfet.add_article']),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ 'name': 'Article',
+ 'category': self.category.pk,
+ 'stock': 5,
+ 'price': '2.5',
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.category = ArticleCategory.objects.create(name='Category')
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+
+ article = Article.objects.get(name='Article')
+
+ self.assertRedirects(r, article.get_absolute_url())
+
+ self.assertInstanceExpected(article, {
+ 'name': 'Article',
+ 'category': self.category,
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class ArticleReadViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.article.read'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.article.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/articles/{}'.format(self.article.pk)
+
+ def setUp(self):
+ super().setUp()
+ self.article = Article.objects.create(
+ name='Article',
+ category=ArticleCategory.objects.create(name='Category'),
+ stock=5,
+ price=Decimal('2.5'),
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.context['article'], self.article)
+
+
+class ArticleUpdateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.article.update'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.article.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/articles/{}/edit'.format(self.article.pk)
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.change_article',
+ ]),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ 'name': 'The Article',
+ 'category': self.article.category.pk,
+ 'is_sold': '1',
+ 'price': '3.5',
+ 'box_type': 'carton',
+ # 'hidden': not checked
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.category = ArticleCategory.objects.create(name='Category')
+ self.article = Article.objects.create(
+ name='Article',
+ category=self.category,
+ stock=5,
+ price=Decimal('2.5'),
+ )
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+
+ self.assertRedirects(r, self.article.get_absolute_url())
+
+ self.article.refresh_from_db()
+
+ self.assertInstanceExpected(self.article, {
+ 'name': 'The Article',
+ 'price': Decimal('3.5'),
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.article.stat.sales.list'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.article.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/articles/{}/stat/sales/list'.format(self.article.pk)
+
+ def setUp(self):
+ super().setUp()
+ self.article = Article.objects.create(
+ name='Article',
+ category=ArticleCategory.objects.create(name='Category'),
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ content = json.loads(r.content.decode('utf-8'))
+
+ base_url = reverse('kfet.article.stat.sales', args=[self.article.pk])
+
+ expected_stats = [
+ {
+ 'label': 'Derniers mois',
+ 'url': {
+ 'path': base_url,
+ 'query': {
+ 'scale_n_steps': ['7'],
+ 'scale_name': ['month'],
+ 'scale_last': ['True'],
+ },
+ },
+ },
+ {
+ 'label': 'Dernières semaines',
+ 'url': {
+ 'path': base_url,
+ 'query': {
+ 'scale_n_steps': ['7'],
+ 'scale_name': ['week'],
+ 'scale_last': ['True'],
+ },
+ },
+ },
+ {
+ 'label': 'Derniers jours',
+ 'url': {
+ 'path': base_url,
+ 'query': {
+ 'scale_n_steps': ['7'],
+ 'scale_name': ['day'],
+ 'scale_last': ['True'],
+ },
+ },
+ },
+ ]
+
+ for stat, expected in zip(content['stats'], expected_stats):
+ expected_url = expected.pop('url')
+ self.assertUrlsEqual(stat['url'], expected_url)
+ self.assertDictContainsSubset(expected, stat)
+
+
+class ArticleStatSalesViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.article.stat.sales'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.article.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/articles/{}/stat/sales'.format(self.article.pk)
+
+ def setUp(self):
+ super().setUp()
+ self.article = Article.objects.create(
+ name='Article',
+ category=ArticleCategory.objects.create(name='Category'),
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class KPsulViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.kpsul'
+ url_expected = '/k-fet/k-psul/'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class KPsulCheckoutDataViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.kpsul.checkout_data'
+ url_expected = '/k-fet/k-psul/checkout_data'
+
+ http_methods = ['POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def setUp(self):
+ super().setUp()
+ 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=5),
+ )
+
+ def test_ok(self):
+ r = self.client.post(self.url, {'pk': self.checkout.pk})
+ self.assertEqual(r.status_code, 200)
+
+ content = json.loads(r.content.decode('utf-8'))
+
+ expected = {
+ 'name': 'Checkout',
+ 'balance': '10.00',
+ }
+
+ self.assertDictContainsSubset(expected, content)
+
+ self.assertSetEqual(set(content.keys()), set([
+ 'balance', 'id', 'name', 'valid_from', 'valid_to',
+ 'last_statement_at', 'last_statement_balance',
+ 'last_statement_by_first_name', 'last_statement_by_last_name',
+ 'last_statement_by_trigramme',
+ ]))
+
+
+class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.kpsul.perform_operations'
+ url_expected = '/k-fet/k-psul/perform_operations'
+
+ http_methods = ['POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ pass
+
+
+class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.kpsul.cancel_operations'
+ url_expected = '/k-fet/k-psul/cancel_operations'
+
+ http_methods = ['POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ pass
+
+
+class KPsulArticlesData(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.kpsul.articles_data'
+ url_expected = '/k-fet/k-psul/articles_data'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def setUp(self):
+ super().setUp()
+ category = ArticleCategory.objects.create(name='Catégorie')
+ self.article1 = Article.objects.create(
+ category=category,
+ name='Article 1',
+ )
+ self.article2 = Article.objects.create(
+ category=category,
+ name='Article 2',
+ price=Decimal('2.5'),
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ content = json.loads(r.content.decode('utf-8'))
+
+ articles = content['articles']
+
+ expected_list = [{
+ 'category__name': 'Catégorie',
+ 'name': 'Article 1',
+ 'price': '0.00',
+ }, {
+ 'category__name': 'Catégorie',
+ 'name': 'Article 2',
+ 'price': '2.50',
+ }]
+
+ for expected, article in zip(expected_list, articles):
+ self.assertDictContainsSubset(expected, article)
+ self.assertSetEqual(set(article.keys()), set([
+ 'id', 'name', 'price', 'stock',
+ 'category_id', 'category__name', 'category__has_addcost',
+ ]))
+
+
+class KPsulUpdateAddcost(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.kpsul.update_addcost'
+ url_expected = '/k-fet/k-psul/update_addcost'
+
+ http_methods = ['POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ post_data = {
+ 'trigramme': '000',
+ 'amount': '0.5',
+ }
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.manage_addcosts',
+ ]),
+ }
+
+ def test_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertEqual(r.status_code, 200)
+
+ self.assertEqual(
+ kfet_config.addcost_for,
+ Account.objects.get(trigramme='000'),
+ )
+ self.assertEqual(kfet_config.addcost_amount, Decimal('0.5'))
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbidden(r)
+
+
+class KPsulGetSettings(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.kpsul.get_settings'
+ url_expected = '/k-fet/k-psul/get_settings'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class HistoryJSONViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.history.json'
+ url_expected = '/k-fet/history.json'
+
+ auth_user = 'user'
+ auth_forbidden = [None]
+
+ def test_ok(self):
+ r = self.client.post(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class AccountReadJSONViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.account.read.json'
+ url_expected = '/k-fet/accounts/read.json'
+
+ http_methods = ['POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ r = self.client.post(self.url, {'trigramme': '000'})
+ self.assertEqual(r.status_code, 200)
+
+ content = json.loads(r.content.decode('utf-8'))
+
+ expected = {
+ 'name': 'first last',
+ 'trigramme': '000',
+ 'balance': '0.00',
+ }
+ self.assertDictContainsSubset(expected, content)
+
+ self.assertSetEqual(set(content.keys()), set([
+ 'balance', 'departement', 'email', 'id', 'is_cof', 'is_frozen',
+ 'name', 'nickname', 'promo', 'trigramme',
+ ]))
+
+
+class SettingsListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.settings'
+ url_expected = '/k-fet/settings/'
+
+ auth_user = 'team1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.see_config',
+ ]),
+ }
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class SettingsUpdateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.settings.update'
+ url_expected = '/k-fet/settings/edit'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team1'
+ auth_forbidden = [None, 'user', 'team']
+
+ @property
+ def post_data(self):
+ return {
+ 'kfet_reduction_cof': '25',
+ 'kfet_addcost_amount': '0.5',
+ 'kfet_addcost_for': self.accounts['user'].pk,
+ 'kfet_overdraft_duration': '2 00:00:00',
+ 'kfet_overdraft_amount': '25',
+ 'kfet_cancel_duration': '00:20:00',
+ }
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.change_config',
+ ]),
+ }
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ r = self.client.post(self.url, self.post_data)
+ # Redirect is skipped because client may lack permissions.
+ self.assertRedirects(
+ r,
+ reverse('kfet.settings'),
+ fetch_redirect_response=False,
+ )
+
+ expected_config = {
+ 'reduction_cof': Decimal('25'),
+ 'addcost_amount': Decimal('0.5'),
+ 'addcost_for': self.accounts['user'],
+ 'overdraft_duration': timedelta(days=2),
+ 'overdraft_amount': Decimal('25'),
+ 'cancel_duration': timedelta(minutes=20),
+ }
+
+ for key, expected in expected_config.items():
+ self.assertEqual(getattr(kfet_config, key), expected)
+
+
+class TransferListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.transfers'
+ url_expected = '/k-fet/transfers/'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class TransferCreateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.transfers.create'
+ url_expected = '/k-fet/transfers/new'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class TransferPerformViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.transfers.perform'
+ url_expected = '/k-fet/transfers/perform'
+
+ http_methods = ['POST']
+
+ auth_user = 'team1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ # Required
+ 'kfet.add_transfer',
+ # Convenience
+ 'kfet.perform_negative_operations',
+ ]),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ # General
+ 'comment': '',
+ # Formset management
+ 'form-TOTAL_FORMS': '10',
+ 'form-INITIAL_FORMS': '0',
+ 'form-MIN_NUM_FORMS': '1',
+ 'form-MAX_NUM_FORMS': '1000',
+ # Transfer 1
+ 'form-0-from_acc': str(self.accounts['user'].pk),
+ 'form-0-to_acc': str(self.accounts['team'].pk),
+ 'form-0-amount': '3.5',
+ # Transfer 2
+ 'form-1-from_acc': str(self.accounts['team'].pk),
+ 'form-1-to_acc': str(self.accounts['team1'].pk),
+ 'form-1-amount': '2.4',
+ }
+
+ def test_ok(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertEqual(r.status_code, 200)
+
+ user = self.accounts['user']
+ user.refresh_from_db()
+ self.assertEqual(user.balance, Decimal('-3.5'))
+
+ team = self.accounts['team']
+ team.refresh_from_db()
+ self.assertEqual(team.balance, Decimal('1.1'))
+
+ team1 = self.accounts['team1']
+ team1.refresh_from_db()
+ self.assertEqual(team1.balance, Decimal('2.4'))
+
+
+class TransferCancelViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.transfers.cancel'
+ url_expected = '/k-fet/transfers/cancel'
+
+ http_methods = ['POST']
+
+ auth_user = 'team1'
+ auth_forbidden = [None, 'user', 'team']
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ # Convenience
+ 'kfet.perform_negative_operations',
+ ]),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ 'transfers[]': [self.transfer1.pk, self.transfer2.pk],
+ }
+
+ def setUp(self):
+ super().setUp()
+ group = TransferGroup.objects.create()
+ self.transfer1 = Transfer.objects.create(
+ group=group,
+ from_acc=self.accounts['user'],
+ to_acc=self.accounts['team'],
+ amount='3.5',
+ )
+ self.transfer2 = Transfer.objects.create(
+ group=group,
+ from_acc=self.accounts['team'],
+ to_acc=self.accounts['root'],
+ amount='2.4',
+ )
+
+ def test_ok(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertEqual(r.status_code, 200)
+
+ user = self.accounts['user']
+ user.refresh_from_db()
+ self.assertEqual(user.balance, Decimal('3.5'))
+
+ team = self.accounts['team']
+ team.refresh_from_db()
+ self.assertEqual(team.balance, Decimal('-1.1'))
+
+ root = self.accounts['root']
+ root.refresh_from_db()
+ self.assertEqual(root.balance, Decimal('-2.4'))
+
+
+class InventoryListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.inventory'
+ url_expected = '/k-fet/inventaires/'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def setUp(self):
+ super().setUp()
+ self.inventory = Inventory.objects.create(
+ by=self.accounts['team'],
+ )
+ category = ArticleCategory.objects.create(name='Category')
+ article = Article.objects.create(
+ name='Article',
+ category=category,
+ )
+ InventoryArticle.objects.create(
+ inventory=self.inventory,
+ article=article,
+ stock_old=5,
+ stock_new=0,
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ inventories = r.context['inventories']
+ self.assertQuerysetEqual(
+ inventories,
+ map(repr, [self.inventory]),
+ )
+
+
+class InventoryCreateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.inventory.create'
+ url_expected = '/k-fet/inventaires/new'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.add_inventory',
+ ]),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ # Formset management
+ 'form-TOTAL_FORMS': '2',
+ 'form-INITIAL_FORMS': '2',
+ 'form-MIN_NUM_FORMS': '0',
+ 'form-MAX_NUM_FORMS': '1000',
+ # Article 1
+ 'form-0-article': str(self.article1.pk),
+ 'form-0-stock_new': '5',
+ # Article 2
+ 'form-1-article': str(self.article2.pk),
+ 'form-1-stock_new': '10',
+ }
+
+ def setUp(self):
+ super().setUp()
+ category = ArticleCategory.objects.create(name='Category')
+ self.article1 = Article.objects.create(
+ category=category,
+ name='Article 1',
+ )
+ self.article2 = Article.objects.create(
+ category=category,
+ name='Article 2',
+ )
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertRedirects(r, reverse('kfet.inventory'))
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class InventoryReadViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.inventory.read'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.inventory.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/inventaires/{}'.format(self.inventory.pk)
+
+ def setUp(self):
+ super().setUp()
+ self.inventory = Inventory.objects.create(
+ by=self.accounts['team'],
+ )
+ category = ArticleCategory.objects.create(name='Category')
+ article = Article.objects.create(
+ name='Article',
+ category=category,
+ )
+ InventoryArticle.objects.create(
+ inventory=self.inventory,
+ article=article,
+ stock_old=5,
+ stock_new=0,
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class OrderListViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.order'
+ url_expected = '/k-fet/orders/'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ def setUp(self):
+ super().setUp()
+ category = ArticleCategory.objects.create(name='Category')
+ article = Article.objects.create(name='Article', category=category)
+
+ supplier = Supplier.objects.create(name='Supplier')
+ SupplierArticle.objects.create(supplier=supplier, article=article)
+
+ self.order = Order.objects.create(supplier=supplier)
+ OrderArticle.objects.create(
+ order=self.order,
+ article=article,
+ quantity_ordered=24,
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ orders = r.context['orders']
+ self.assertQuerysetEqual(
+ orders,
+ map(repr, [self.order]),
+ )
+
+
+class OrderReadViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.order.read'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.order.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/orders/{}'.format(self.order.pk)
+
+ def setUp(self):
+ super().setUp()
+ category = ArticleCategory.objects.create(name='Category')
+ article = Article.objects.create(name='Article', category=category)
+
+ supplier = Supplier.objects.create(name='Supplier')
+ SupplierArticle.objects.create(supplier=supplier, article=article)
+
+ self.order = Order.objects.create(supplier=supplier)
+ OrderArticle.objects.create(
+ order=self.order,
+ article=article,
+ quantity_ordered=24,
+ )
+
+ def test_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+
+class SupplierUpdateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.order.supplier.update'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.supplier.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/orders/suppliers/{}/edit'.format(self.supplier.pk)
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.change_supplier',
+ ]),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ 'name': 'The Supplier',
+ 'phone': '',
+ 'comment': '',
+ 'address': '',
+ 'email': '',
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.supplier = Supplier.objects.create(name='Supplier')
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ def test_post_ok(self):
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertRedirects(r, reverse('kfet.order'))
+
+ self.supplier.refresh_from_db()
+ self.assertEqual(self.supplier.name, 'The Supplier')
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class OrderCreateViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.order.new'
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.supplier.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/orders/suppliers/{}/new-order'.format(self.supplier.pk)
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=['kfet.add_order']),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ # Formset management
+ 'form-TOTAL_FORMS': '1',
+ 'form-INITIAL_FORMS': '1',
+ 'form-MIN_NUM_FORMS': '0',
+ 'form-MAX_NUM_FORMS': '1000',
+ # Article
+ 'form-0-article': self.article.pk,
+ 'form-0-quantity_ordered': '20',
+ }
+
+ def setUp(self):
+ super().setUp()
+ category = ArticleCategory.objects.create(name='Category')
+ self.article = Article.objects.create(
+ name='Article',
+ category=category,
+ )
+
+ self.supplier = Supplier.objects.create(name='Supplier')
+ SupplierArticle.objects.create(
+ supplier=self.supplier,
+ article=self.article,
+ )
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ @mock.patch('django.utils.timezone.now')
+ def test_post_ok(self, mock_now):
+ mock_now.return_value = self.now
+
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+
+ order = Order.objects.get(at=self.now)
+
+ self.assertRedirects(r, reverse('kfet.order.read', args=[order.pk]))
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
+
+
+class OrderToInventoryViewTests(ViewTestCaseMixin, TestCase):
+ url_name = 'kfet.order.to_inventory'
+
+ http_methods = ['GET', 'POST']
+
+ auth_user = 'team'
+ auth_forbidden = [None, 'user']
+
+ @property
+ def url_kwargs(self):
+ return {'pk': self.order.pk}
+
+ @property
+ def url_expected(self):
+ return '/k-fet/orders/{}/to_inventory'.format(self.order.pk)
+
+ def get_users_extra(self):
+ return {
+ 'team1': create_team('team1', '101', perms=[
+ 'kfet.order_to_inventory',
+ ]),
+ }
+
+ @property
+ def post_data(self):
+ return {
+ # Formset mangaement
+ 'form-TOTAL_FORMS': '1',
+ 'form-INITIAL_FORMS': '1',
+ 'form-MIN_NUM_FORMS': '0',
+ 'form-MAX_NUM_FORMS': '1000',
+ # Article 1
+ 'form-0-article': self.article.pk,
+ 'form-0-quantity_received': '20',
+ 'form-0-price_HT': '',
+ 'form-0-TVA': '',
+ 'form-0-rights': '',
+ }
+
+ def setUp(self):
+ super().setUp()
+ category = ArticleCategory.objects.create(name='Category')
+ self.article = Article.objects.create(
+ name='Article',
+ category=category,
+ )
+
+ supplier = Supplier.objects.create(name='Supplier')
+ SupplierArticle.objects.create(supplier=supplier, article=self.article)
+
+ self.order = Order.objects.create(supplier=supplier)
+ OrderArticle.objects.create(
+ order=self.order,
+ article=self.article,
+ quantity_ordered=24,
+ )
+
+ def test_get_ok(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+
+ @mock.patch('django.utils.timezone.now')
+ def test_post_ok(self, mock_now):
+ mock_now.return_value = self.now
+
+ client = Client()
+ client.login(username='team1', password='team1')
+
+ r = client.post(self.url, self.post_data)
+ self.assertRedirects(r, reverse('kfet.order'))
+
+ inventory = Inventory.objects.first()
+
+ self.assertInstanceExpected(inventory, {
+ 'by': self.accounts['team1'],
+ 'at': self.now,
+ 'order': self.order,
+ })
+ self.assertQuerysetEqual(
+ inventory.articles.all(),
+ map(repr, [self.article]),
+ )
+
+ compte = InventoryArticle.objects.get(article=self.article)
+
+ self.assertInstanceExpected(compte, {
+ 'stock_old': 0,
+ 'stock_new': 20,
+ 'stock_error': 0,
+ })
+
+ def test_post_forbidden(self):
+ r = self.client.post(self.url, self.post_data)
+ self.assertForbiddenKfet(r)
diff --git a/kfet/tests/testcases.py b/kfet/tests/testcases.py
new file mode 100644
index 00000000..aa2fb1b6
--- /dev/null
+++ b/kfet/tests/testcases.py
@@ -0,0 +1,347 @@
+from unittest import mock
+from urllib.parse import parse_qs, urlparse
+
+from django.core.urlresolvers import reverse
+from django.http import QueryDict
+from django.test import Client
+from django.utils import timezone
+from django.utils.functional import cached_property
+
+from .utils import create_root, create_team, create_user
+
+
+class TestCaseMixin:
+ """Extends TestCase for kfet application tests."""
+
+ def assertForbidden(self, response):
+ """
+ Test that the response (retrieved with a Client) is a denial of access.
+
+ The response should verify one of the following:
+ - its HTTP response code is 403,
+ - it redirects to the login page with a GET parameter named 'next'
+ whose value is the url of the requested page.
+
+ """
+ request = response.wsgi_request
+
+ try:
+ try:
+ # Is this an HTTP Forbidden response ?
+ self.assertEqual(response.status_code, 403)
+ except AssertionError:
+ # A redirection to the login view is fine too.
+
+ # Let's build the login url with the 'next' param on current
+ # page.
+ full_path = request.get_full_path()
+
+ querystring = QueryDict(mutable=True)
+ querystring['next'] = full_path
+
+ login_url = '/login?' + querystring.urlencode(safe='/')
+
+ # We don't focus on what the login view does.
+ # So don't fetch the redirect.
+ self.assertRedirects(
+ response, login_url,
+ fetch_redirect_response=False,
+ )
+ except AssertionError:
+ raise AssertionError(
+ "%(http_method)s request at %(path)s should be forbidden for "
+ "%(username)s user.\n"
+ "Response isn't 403, nor a redirect to login view. Instead, "
+ "response code is %(code)d." % {
+ 'http_method': request.method,
+ 'path': request.get_full_path(),
+ 'username': (
+ "'{}'".format(request.user)
+ if request.user.is_authenticated
+ else 'anonymous'
+ ),
+ 'code': response.status_code,
+ }
+ )
+
+ def assertForbiddenKfet(self, response, form_ctx='form'):
+ """
+ Test that a response (retrieved with a Client) contains error due to
+ lack of kfet permissions.
+
+ It checks that 'Permission refusée' is present in the non-field errors
+ of the form of response context at key 'form_ctx', or present in
+ messages.
+
+ This should be used for pages which can be accessed by the kfet team
+ members, but require additionnal permission(s) to make an operation.
+
+ """
+ try:
+ self.assertEqual(response.status_code, 200)
+ try:
+ form = response.context[form_ctx]
+ self.assertIn("Permission refusée", form.non_field_errors())
+ except (AssertionError, AttributeError, KeyError):
+ messages = [str(msg) for msg in response.context['messages']]
+ self.assertIn("Permission refusée", messages)
+ except AssertionError:
+ request = response.wsgi_request
+ raise AssertionError(
+ "%(http_method)s request at %(path)s should raise an error "
+ "for %(username)s user.\n"
+ "Cannot find any errors in non-field errors of form "
+ "'%(form_ctx)s', nor in messages." % {
+ 'http_method': request.method,
+ 'path': request.get_full_path(),
+ 'username': (
+ "'%s'" % request.user
+ if request.user.is_authenticated
+ else 'anonymous'
+ ),
+ 'form_ctx': form_ctx,
+ }
+ )
+
+ def assertInstanceExpected(self, instance, expected):
+ """
+ Test that the values of the attributes and without-argument methods of
+ 'instance' are equal to 'expected' pairs.
+ """
+ for attr, expected_value in expected.items():
+ value = getattr(instance, attr)
+ if callable(value):
+ value = value()
+ self.assertEqual(value, expected_value)
+
+ def assertUrlsEqual(self, actual, expected):
+ """
+ Test that the url 'actual' is as 'expected'.
+
+ Arguments:
+ actual (str): Url to verify.
+ expected: Two forms are accepted.
+ * (str): Expected url. Strings equality is checked.
+ * (dict): Its keys must be attributes of 'urlparse(actual)'.
+ Equality is checked for each present key, except for
+ 'query' which must be a dict of the expected query string
+ parameters.
+
+ """
+ if type(expected) == dict:
+ parsed = urlparse(actual)
+ for part, expected_part in expected.items():
+ if part == 'query':
+ self.assertDictEqual(
+ parse_qs(parsed.query),
+ expected.get('query', {}),
+ )
+ else:
+ self.assertEqual(getattr(parsed, part), expected_part)
+ else:
+ self.assertEqual(actual, expected)
+
+
+class ViewTestCaseMixin(TestCaseMixin):
+ """
+ TestCase extension to ease tests of kfet views.
+
+
+ Urls concerns
+ -------------
+
+ # Basic usage
+
+ Attributes:
+ url_name (str): Name of view under test, as given to 'reverse'
+ function.
+ url_args (list, optional): Will be given to 'reverse' call.
+ url_kwargs (dict, optional): Same.
+ url_expcted (str): What 'reverse' should return given previous
+ attributes.
+
+ View url can then be accessed at the 'url' attribute.
+
+ # Advanced usage
+
+ If multiple combinations of url name, args, kwargs can be used for a view,
+ it is possible to define 'urls_conf' attribute. It must be a list whose
+ each item is a dict defining arguments for 'reverse' call ('name', 'args',
+ 'kwargs' keys) and its expected result ('expected' key).
+
+ The reversed urls can be accessed at the 't_urls' attribute.
+
+
+ Users concerns
+ --------------
+
+ During setup, three users are created with their kfet account:
+ - 'user': a basic user without any permission, account trigramme: 000,
+ - 'team': a user with kfet.is_team permission, account trigramme: 100,
+ - 'root': a superuser, account trigramme: 200.
+ Their password is their username.
+
+ One can create additionnal users with 'get_users_extra' method, or prevent
+ these 3 users to be created with 'get_users_base' method. See these two
+ methods for further informations.
+
+ By using 'register_user' method, these users can then be accessed at
+ 'users' attribute by their label. Similarly, their kfet account is
+ registered on 'accounts' attribute.
+
+ A user label can be given to 'auth_user' attribute. The related user is
+ then authenticated on self.client during test setup. Its value defaults to
+ 'None', meaning no user is authenticated.
+
+
+ Automated tests
+ ---------------
+
+ # Url reverse
+
+ Based on url-related attributes/properties, the test 'test_urls' checks
+ that expected url is returned by 'reverse' (once with basic url usage and
+ each for advanced usage).
+
+ # Forbidden responses
+
+ The 'test_forbidden' test verifies that each user, from labels of
+ 'auth_forbidden' attribute, can't access the url(s), i.e. response should
+ be a 403, or a redirect to login view.
+
+ Tested HTTP requests are given by 'http_methods' attribute. Additional data
+ can be given by defining an attribute '
_data'.
+
+ """
+ url_name = None
+ url_expected = None
+
+ http_methods = ['GET']
+
+ auth_user = None
+ auth_forbidden = []
+
+ def setUp(self):
+ """
+ Warning: Do not forget to call super().setUp() in subclasses.
+ """
+ # Signals handlers on login/logout send messages.
+ # Due to the way the Django' test Client performs login, this raise an
+ # error. As workaround, we mock the Django' messages module.
+ patcher_messages = mock.patch('gestioncof.signals.messages')
+ patcher_messages.start()
+ self.addCleanup(patcher_messages.stop)
+
+ # A test can mock 'django.utils.timezone.now' and give this as return
+ # value. E.g. it is useful if the test checks values of 'auto_now' or
+ # 'auto_now_add' fields.
+ self.now = timezone.now()
+
+ # These attributes register users and accounts instances.
+ self.users = {}
+ self.accounts = {}
+
+ for label, user in dict(self.users_base, **self.users_extra).items():
+ self.register_user(label, user)
+
+ if self.auth_user:
+ self.client.force_login(self.users[self.auth_user])
+
+ def tearDown(self):
+ del self.users_base
+ del self.users_extra
+
+ def get_users_base(self):
+ """
+ Dict of .
+
+ Note: Don't access yourself this property. Use 'users_base' attribute
+ which cache the returned value from here.
+ It allows to give functions calls, which creates users instances, as
+ values here.
+
+ """
+ # Format desc: username, password, trigramme
+ return {
+ # user, user, 000
+ 'user': create_user(),
+ # team, team, 100
+ 'team': create_team(),
+ # root, root, 200
+ 'root': create_root(),
+ }
+
+ @cached_property
+ def users_base(self):
+ return self.get_users_base()
+
+ def get_users_extra(self):
+ """
+ Dict of .
+
+ Note: Don't access yourself this property. Use 'users_base' attribute
+ which cache the returned value from here.
+ It allows to give functions calls, which create users instances, as
+ values here.
+
+ """
+ return {}
+
+ @cached_property
+ def users_extra(self):
+ return self.get_users_extra()
+
+ def register_user(self, label, user):
+ self.users[label] = user
+ if hasattr(user.profile, 'account_kfet'):
+ self.accounts[label] = user.profile.account_kfet
+
+ def get_user(self, label):
+ if self.auth_user is not None:
+ return self.auth_user
+ return self.auth_user_mapping.get(label)
+
+ @property
+ def urls_conf(self):
+ return [{
+ 'name': self.url_name,
+ 'args': getattr(self, 'url_args', []),
+ 'kwargs': getattr(self, 'url_kwargs', {}),
+ 'expected': self.url_expected,
+ }]
+
+ @property
+ def t_urls(self):
+ return [
+ reverse(
+ url_conf['name'],
+ args=url_conf.get('args', []),
+ kwargs=url_conf.get('kwargs', {}),
+ )
+ for url_conf in self.urls_conf]
+
+ @property
+ def url(self):
+ return self.t_urls[0]
+
+ def test_urls(self):
+ for url, conf in zip(self.t_urls, self.urls_conf):
+ self.assertEqual(url, conf['expected'])
+
+ def test_forbidden(self):
+ for method in self.http_methods:
+ for user in self.auth_forbidden:
+ for url in self.t_urls:
+ self.check_forbidden(method, url, user)
+
+ def check_forbidden(self, method, url, user=None):
+ method = method.lower()
+ client = Client()
+ if user is not None:
+ client.login(username=user, password=user)
+
+ send_request = getattr(client, method)
+ data = getattr(self, '{}_data'.format(method), {})
+
+ r = send_request(url, data)
+ self.assertForbidden(r)
diff --git a/kfet/tests/utils.py b/kfet/tests/utils.py
new file mode 100644
index 00000000..f3222e14
--- /dev/null
+++ b/kfet/tests/utils.py
@@ -0,0 +1,188 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Permission
+
+from ..models import Account
+
+
+User = get_user_model()
+
+
+def _create_user_and_account(user_attrs, account_attrs, perms=None):
+ """
+ Create a user and its account, and assign permissions to this user.
+
+ Arguments
+ user_attrs (dict): User data (first name, last name, password...).
+ account_attrs (dict): Account data (department, kfet password...).
+ perms (list of str: 'app.perm'): These permissions will be assigned to
+ the created user. No permission are assigned by default.
+
+ If 'password' is not given in 'user_attrs', username is used as password.
+
+ If 'kfet.is_team' is in 'perms' and 'password' is not in 'account_attrs',
+ the account password is 'kfetpwd_'.
+
+ """
+ user_pwd = user_attrs.pop('password', user_attrs['username'])
+ user = User.objects.create(**user_attrs)
+ user.set_password(user_pwd)
+ user.save()
+
+ account_attrs['cofprofile'] = user.profile
+ kfet_pwd = account_attrs.pop('password', 'kfetpwd_{}'.format(user_pwd))
+
+ account = Account.objects.create(**account_attrs)
+
+ if perms is not None:
+ user = user_add_perms(user, perms)
+
+ if 'kfet.is_team' in perms:
+ account.change_pwd(kfet_pwd)
+ account.save()
+
+ return user
+
+
+def create_user(username='user', trigramme='000', **kwargs):
+ """
+ Create a user without any permission and its kfet account.
+
+ username and trigramme are accepted as arguments (defaults to 'user' and
+ '000').
+
+ user_attrs, account_attrs and perms can be given as keyword arguments to
+ customize the user and its kfet account.
+
+ # Default values
+
+ User
+ * username: user
+ * password: user
+ * first_name: first
+ * last_name: last
+ * email: mail@user.net
+ Account
+ * trigramme: 000
+
+ """
+ user_attrs = kwargs.setdefault('user_attrs', {})
+
+ user_attrs.setdefault('username', username)
+ user_attrs.setdefault('first_name', 'first')
+ user_attrs.setdefault('last_name', 'last')
+ user_attrs.setdefault('email', 'mail@user.net')
+
+ account_attrs = kwargs.setdefault('account_attrs', {})
+ account_attrs.setdefault('trigramme', trigramme)
+
+ return _create_user_and_account(**kwargs)
+
+
+def create_team(username='team', trigramme='100', **kwargs):
+ """
+ Create a user, member of the kfet team, and its kfet account.
+
+ username and trigramme are accepted as arguments (defaults to 'team' and
+ '100').
+
+ user_attrs, account_attrs and perms can be given as keyword arguments to
+ customize the user and its kfet account.
+
+ # Default values
+
+ User
+ * username: team
+ * password: team
+ * first_name: team
+ * last_name: member
+ * email: mail@team.net
+ Account
+ * trigramme: 100
+ * kfet password: kfetpwd_team
+
+ """
+ user_attrs = kwargs.setdefault('user_attrs', {})
+
+ user_attrs.setdefault('username', username)
+ user_attrs.setdefault('first_name', 'team')
+ user_attrs.setdefault('last_name', 'member')
+ user_attrs.setdefault('email', 'mail@team.net')
+
+ account_attrs = kwargs.setdefault('account_attrs', {})
+ account_attrs.setdefault('trigramme', trigramme)
+
+ perms = kwargs.setdefault('perms', [])
+ perms.append('kfet.is_team')
+
+ return _create_user_and_account(**kwargs)
+
+
+def create_root(username='root', trigramme='200', **kwargs):
+ """
+ Create a superuser and its kfet account.
+
+ username and trigramme are accepted as arguments (defaults to 'root' and
+ '200').
+
+ user_attrs, account_attrs and perms can be given as keyword arguments to
+ customize the user and its kfet account.
+
+ # Default values
+
+ User
+ * username: root
+ * password: root
+ * first_name: super
+ * last_name: user
+ * email: mail@root.net
+ * is_staff, is_superuser: True
+ Account
+ * trigramme: 200
+ * kfet password: kfetpwd_root
+
+ """
+ user_attrs = kwargs.setdefault('user_attrs', {})
+
+ user_attrs.setdefault('username', username)
+ user_attrs.setdefault('first_name', 'super')
+ user_attrs.setdefault('last_name', 'user')
+ user_attrs.setdefault('email', 'mail@root.net')
+ user_attrs['is_superuser'] = user_attrs['is_staff'] = True
+
+ account_attrs = kwargs.setdefault('account_attrs', {})
+ account_attrs.setdefault('trigramme', trigramme)
+
+ return _create_user_and_account(**kwargs)
+
+
+def get_perms(*labels):
+ """Return Permission instances from a list of '.'."""
+ perms = {}
+ for label in set(labels):
+ app_label, codename = label.split('.', 1)
+ perms[label] = Permission.objects.get(
+ content_type__app_label=app_label,
+ codename=codename,
+ )
+ return perms
+
+
+def user_add_perms(user, perms_labels):
+ """
+ Add perms to a user.
+
+ Args:
+ user (User instance)
+ perms (list of str 'app.perm_name')
+
+ Returns:
+ The same user (refetched from DB to avoid missing perms)
+
+ """
+ perms = get_perms(*perms_labels)
+ user.user_permissions.add(*perms.values())
+
+ # If permissions have already been fetched for this user, we need to reload
+ # it to avoid using of the previous permissions cache.
+ # https://docs.djangoproject.com/en/dev/topics/auth/default/#permission-caching
+ return User.objects.get(pk=user.pk)
diff --git a/kfet/urls.py b/kfet/urls.py
index c762d237..96fd4ddf 100644
--- a/kfet/urls.py
+++ b/kfet/urls.py
@@ -1,16 +1,13 @@
-# -*- coding: utf-8 -*-
-
-from django.conf.urls import url
+from django.conf.urls import include, url
from django.contrib.auth.decorators import permission_required
-from kfet import views
-from kfet import autocomplete
+
+from kfet import autocomplete, views
from kfet.decorators import teamkfet_required
+
urlpatterns = [
- url(r'^$', views.Home.as_view(),
- name='kfet.home'),
- url(r'^login/genericteam$', views.login_genericteam,
- name='kfet.login.genericteam'),
+ url(r'^login/generic$', views.login_generic,
+ name='kfet.login.generic'),
url(r'^history$', views.history,
name='kfet.history'),
@@ -189,15 +186,12 @@ urlpatterns = [
# Settings urls
# -----
- url(r'^settings/$',
- permission_required('kfet.change_settings')
- (views.SettingsList.as_view()),
+ url(r'^settings/$', views.config_list,
name='kfet.settings'),
- url(r'^settings/edit$',
- permission_required('kfet.change_settings')
- (views.SettingsUpdate.as_view()),
+ url(r'^settings/edit$', views.config_update,
name='kfet.settings.update'),
+
# -----
# Transfers urls
# -----
@@ -242,3 +236,8 @@ urlpatterns = [
url(r'^orders/(?P\d+)/to_inventory$', views.order_to_inventory,
name='kfet.order.to_inventory'),
]
+
+urlpatterns += [
+ # K-Fêt Open urls
+ url('^open/', include('kfet.open.urls')),
+]
diff --git a/kfet/utils.py b/kfet/utils.py
new file mode 100644
index 00000000..3d06bb0b
--- /dev/null
+++ b/kfet/utils.py
@@ -0,0 +1,115 @@
+import math
+import json
+
+from django.core.cache import cache
+from django.core.serializers.json import DjangoJSONEncoder
+
+from channels.channel import Group
+from channels.generic.websockets import JsonWebsocketConsumer
+
+from .config import kfet_config
+
+
+def to_ukf(balance, is_cof=False):
+ """Convert euro to UKF."""
+ subvention = kfet_config.subvention_cof
+ grant = (1 + subvention / 100) if is_cof else 1
+ return math.floor(balance * 10 * grant)
+
+# Storage
+
+class CachedMixin:
+ """Object with cached properties.
+
+ Attributes:
+ cached (dict): Keys are cached properties. Associated value is the
+ returned default by getters in case the key is missing from cache.
+ cache_prefix (str): Used to prefix keys in cache.
+
+ """
+ cached = {}
+ cache_prefix = ''
+
+ def __init__(self, cache_prefix=None, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if cache_prefix is not None:
+ self.cache_prefix = cache_prefix
+
+ def cachekey(self, attr):
+ return '{}__{}'.format(self.cache_prefix, attr)
+
+ def __getattr__(self, attr):
+ if attr in self.cached:
+ return cache.get(self.cachekey(attr), self.cached.get(attr))
+ elif hasattr(super(), '__getattr__'):
+ return super().__getattr__(attr)
+ else:
+ raise AttributeError("can't get attribute")
+
+ def __setattr__(self, attr, value):
+ if attr in self.cached:
+ cache.set(self.cachekey(attr), value)
+ elif hasattr(super(), '__setattr__'):
+ super().__setattr__(attr, value)
+ else:
+ raise AttributeError("can't set attribute")
+
+ def clear_cache(self):
+ cache.delete_many([
+ self.cachekey(attr) for attr in self.cached.keys()
+ ])
+
+
+# Consumers
+
+class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer):
+ """Custom Json Websocket Consumer.
+
+ Encode to JSON with DjangoJSONEncoder.
+
+ """
+
+ @classmethod
+ def encode_json(cls, content):
+ return json.dumps(content, cls=DjangoJSONEncoder)
+
+
+class PermConsumerMixin:
+ """Add support to check permissions on consumers.
+
+ Attributes:
+ perms_connect (list): Required permissions to connect to this
+ consumer.
+
+ message.user is appended as argument to each connection_groups method call.
+
+ """
+ http_user = True # Enable message.user
+ perms_connect = []
+
+ def connect(self, message, **kwargs):
+ """Check permissions on connection."""
+ if message.user.has_perms(self.perms_connect):
+ super().connect(message, **kwargs)
+ else:
+ self.close()
+
+ def raw_connect(self, message, **kwargs):
+ # Same as original raw_connect method of JsonWebsocketConsumer
+ # We add user to connection_groups call.
+ groups = self.connection_groups(user=message.user, **kwargs)
+ for group in groups:
+ Group(group, channel_layer=message.channel_layer).add(message.reply_channel)
+ self.connect(message, **kwargs)
+
+ def raw_disconnect(self, message, **kwargs):
+ # Same as original raw_connect method of JsonWebsocketConsumer
+ # We add user to connection_groups call.
+ groups = self.connection_groups(user=message.user, **kwargs)
+ for group in groups:
+ Group(group, channel_layer=message.channel_layer).discard(message.reply_channel)
+ self.disconnect(message, **kwargs)
+
+ def connection_groups(self, user, **kwargs):
+ """`message.user` is available as `user` arg. Original behavior."""
+ return super().connection_groups(user=user, **kwargs)
diff --git a/kfet/views.py b/kfet/views.py
index 6a8bb636..29f7411a 100644
--- a/kfet/views.py
+++ b/kfet/views.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
import ast
from urllib.parse import urlencode
@@ -12,31 +10,30 @@ from django.views.generic.edit import CreateView, UpdateView
from django.core.urlresolvers import reverse, reverse_lazy
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
-from django.contrib.auth import authenticate, login
from django.contrib.auth.decorators import login_required, permission_required
-from django.contrib.auth.models import User, Permission, Group
+from django.contrib.auth.models import User, Permission
from django.http import JsonResponse, Http404
from django.forms import formset_factory
from django.db import transaction
from django.db.models import F, Sum, Prefetch, Count
from django.db.models.functions import Coalesce
from django.utils import timezone
-from django.utils.crypto import get_random_string
from django.utils.decorators import method_decorator
+
from gestioncof.models import CofProfile
from kfet.config import kfet_config
from kfet.decorators import teamkfet_required
from kfet.models import (
Account, Checkout, Article, AccountNegative,
- CheckoutStatement, GenericTeamToken, Supplier, SupplierArticle, Inventory,
+ CheckoutStatement, Supplier, SupplierArticle, Inventory,
InventoryArticle, Order, OrderArticle, Operation, OperationGroup,
TransferGroup, Transfer, ArticleCategory)
from kfet.forms import (
AccountTriForm, AccountBalanceForm, AccountNoTriForm, UserForm, CofForm,
UserRestrictTeamForm, UserGroupForm, AccountForm, CofRestrictForm,
AccountPwdForm, AccountNegativeForm, UserRestrictForm, AccountRestrictForm,
- GroupForm, CheckoutForm, CheckoutRestrictForm, CheckoutStatementCreateForm,
+ CheckoutForm, CheckoutRestrictForm, CheckoutStatementCreateForm,
CheckoutStatementUpdateForm, ArticleForm, ArticleRestrictForm,
KPsulOperationGroupForm, KPsulAccountForm, KPsulCheckoutForm,
KPsulOperationFormSet, AddcostForm, FilterHistoryForm,
@@ -47,59 +44,14 @@ from collections import defaultdict
from kfet import consumers
from datetime import timedelta
from decimal import Decimal
-import django_cas_ng
import heapq
import statistics
from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes, WeekScale
+from .auth.views import ( # noqa
+ account_group, login_generic, AccountGroupCreate, AccountGroupUpdate,
+)
-class Home(TemplateView):
- template_name = "kfet/home.html"
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- articles = list(
- Article.objects
- .filter(is_sold=True, hidden=False)
- .select_related('category')
- .order_by('category__name')
- )
- pressions, others = [], []
- while len(articles) > 0:
- article = articles.pop()
- if article.category.name == 'Pression':
- pressions.append(article)
- else:
- others.append(article)
- context['pressions'], context['articles'] = pressions, others
- return context
-
- @method_decorator(login_required)
- def dispatch(self, *args, **kwargs):
- return super(TemplateView, self).dispatch(*args, **kwargs)
-
-
-@teamkfet_required
-def login_genericteam(request):
- # Check si besoin de déconnecter l'utilisateur de CAS
- profile, _ = CofProfile.objects.get_or_create(user=request.user)
- need_cas_logout = False
- if profile.login_clipper:
- need_cas_logout = True
- # Récupèration de la vue de déconnexion de CAS
- # Ici, car request sera modifié après
- logout_cas = django_cas_ng.views.logout(request)
-
- # Authentification du compte générique
- token = GenericTeamToken.objects.create(token=get_random_string(50))
- user = authenticate(username="kfet_genericteam", token=token.token)
- login(request, user)
-
- if need_cas_logout:
- # Vue de déconnexion de CAS
- return logout_cas
-
- return render(request, "kfet/login_genericteam.html")
def put_cleaned_data_in_dict(dict, form):
for field in form.cleaned_data:
@@ -294,10 +246,10 @@ def get_account_create_forms(request=None, username=None, login_clipper=None,
# Form créations
if request:
- user_form = UserForm(request.POST, initial=user_initial, from_clipper=True)
+ user_form = UserForm(request.POST, initial=user_initial)
cof_form = CofForm(request.POST, initial=cof_initial)
else:
- user_form = UserForm(initial=user_initial, from_clipper=True)
+ user_form = UserForm(initial=user_initial)
cof_form = CofForm(initial=cof_initial)
# Protection (read-only) des champs username et login_clipper
@@ -366,8 +318,9 @@ def account_read(request, trigramme):
account = get_object_or_404(Account, trigramme=trigramme)
# Checking permissions
- if not request.user.has_perm('kfet.is_team') \
- and request.user != account.user:
+ if not account.readable or (
+ not request.user.has_perm('kfet.is_team') and
+ request.user != account.user):
raise PermissionDenied
addcosts = (
@@ -530,37 +483,6 @@ def account_update(request, trigramme):
})
-@permission_required('kfet.manage_perms')
-def account_group(request):
- user_pre = Prefetch(
- 'user_set',
- queryset=User.objects.select_related('profile__account_kfet'),
- )
- groups = (
- Group.objects
- .filter(name__icontains='K-Fêt')
- .prefetch_related('permissions', user_pre)
- )
- return render(request, 'kfet/account_group.html', {
- 'groups': groups,
- })
-
-
-class AccountGroupCreate(SuccessMessageMixin, CreateView):
- model = Group
- template_name = 'kfet/account_group_form.html'
- form_class = GroupForm
- success_message = 'Nouveau groupe : %(name)s'
- success_url = reverse_lazy('kfet.account.group')
-
-class AccountGroupUpdate(UpdateView):
- queryset = Group.objects.filter(name__icontains='K-Fêt')
- template_name = 'kfet/account_group_form.html'
- form_class = GroupForm
- success_message = 'Groupe modifié : %(name)s'
- success_url = reverse_lazy('kfet.account.group')
-
-
class AccountNegativeList(ListView):
queryset = (
AccountNegative.objects
@@ -571,7 +493,7 @@ class AccountNegativeList(ListView):
context_object_name = 'negatives'
def get_context_data(self, **kwargs):
- context = super(AccountNegativeList, self).get_context_data(**kwargs)
+ context = super().get_context_data(**kwargs)
real_balances = (neg.account.real_balance for neg in self.object_list)
context['negatives_sum'] = sum(real_balances)
return context
@@ -604,17 +526,9 @@ class CheckoutCreate(SuccessMessageMixin, CreateView):
# Creating
form.instance.created_by = self.request.user.profile.account_kfet
- checkout = form.save()
+ 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)
-
- return super(CheckoutCreate, self).form_valid(form)
+ return super().form_valid(form)
# Checkout - Read
@@ -624,7 +538,7 @@ class CheckoutRead(DetailView):
context_object_name = 'checkout'
def get_context_data(self, **kwargs):
- context = super(CheckoutRead, self).get_context_data(**kwargs)
+ context = super().get_context_data(**kwargs)
context['statements'] = context['checkout'].statements.order_by('-at')
return context
@@ -643,7 +557,7 @@ class CheckoutUpdate(SuccessMessageMixin, UpdateView):
form.add_error(None, 'Permission refusée')
return self.form_invalid(form)
# Updating
- return super(CheckoutUpdate, self).form_valid(form)
+ return super().form_valid(form)
# -----
# Checkout Statement views
@@ -695,7 +609,7 @@ class CheckoutStatementCreate(SuccessMessageMixin, CreateView):
at = self.object.at)
def get_context_data(self, **kwargs):
- context = super(CheckoutStatementCreate, self).get_context_data(**kwargs)
+ context = super().get_context_data(**kwargs)
checkout = Checkout.objects.get(pk=self.kwargs['pk_checkout'])
context['checkout'] = checkout
return context
@@ -711,7 +625,7 @@ class CheckoutStatementCreate(SuccessMessageMixin, CreateView):
form.instance.balance_new = getAmountBalance(form.cleaned_data)
form.instance.checkout_id = self.kwargs['pk_checkout']
form.instance.by = self.request.user.profile.account_kfet
- return super(CheckoutStatementCreate, self).form_valid(form)
+ return super().form_valid(form)
class CheckoutStatementUpdate(SuccessMessageMixin, UpdateView):
model = CheckoutStatement
@@ -723,7 +637,7 @@ class CheckoutStatementUpdate(SuccessMessageMixin, UpdateView):
return reverse_lazy('kfet.checkout.read', kwargs={'pk':self.kwargs['pk_checkout']})
def get_context_data(self, **kwargs):
- context = super(CheckoutStatementUpdate, self).get_context_data(**kwargs)
+ context = super().get_context_data(**kwargs)
checkout = Checkout.objects.get(pk=self.kwargs['pk_checkout'])
context['checkout'] = checkout
return context
@@ -735,7 +649,7 @@ class CheckoutStatementUpdate(SuccessMessageMixin, UpdateView):
return self.form_invalid(form)
# Updating
form.instance.amount_taken = getAmountTaken(form.instance)
- return super(CheckoutStatementUpdate, self).form_valid(form)
+ return super().form_valid(form)
# -----
# Category views
@@ -767,7 +681,7 @@ class CategoryUpdate(SuccessMessageMixin, UpdateView):
return self.form_invalid(form)
# Updating
- return super(CategoryUpdate, self).form_valid(form)
+ return super().form_valid(form)
# -----
# Article views
@@ -790,6 +704,14 @@ class ArticleList(ListView):
)
template_name = 'kfet/article.html'
context_object_name = 'articles'
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ articles = context[self.context_object_name]
+ context['nb_articles'] = len(articles)
+ context[self.context_object_name] = articles.filter(is_sold=True)
+ context['not_sold_articles'] = articles.filter(is_sold=False)
+ return context
# Article - Create
@@ -834,7 +756,7 @@ class ArticleCreate(SuccessMessageMixin, CreateView):
)
# Creating
- return super(ArticleCreate, self).form_valid(form)
+ return super().form_valid(form)
# Article - Read
@@ -844,7 +766,7 @@ class ArticleRead(DetailView):
context_object_name = 'article'
def get_context_data(self, **kwargs):
- context = super(ArticleRead, self).get_context_data(**kwargs)
+ context = super().get_context_data(**kwargs)
inventoryarts = (InventoryArticle.objects
.filter(article=self.object)
.select_related('inventory')
@@ -896,7 +818,7 @@ class ArticleUpdate(SuccessMessageMixin, UpdateView):
article=article, supplier=supplier)
# Updating
- return super(ArticleUpdate, self).form_valid(form)
+ return super().form_valid(form)
# -----
@@ -937,30 +859,34 @@ def account_read_json(request):
'trigramme': account.trigramme }
return JsonResponse(data)
+
@teamkfet_required
def kpsul_checkout_data(request):
pk = request.POST.get('pk', 0)
if not pk:
pk = 0
- data = (Checkout.objects
+
+ data = (
+ Checkout.objects
.annotate(
last_statement_by_first_name=F('statements__by__cofprofile__user__first_name'),
last_statement_by_last_name=F('statements__by__cofprofile__user__last_name'),
last_statement_by_trigramme=F('statements__by__trigramme'),
last_statement_balance=F('statements__balance_new'),
last_statement_at=F('statements__at'))
- .values(
- 'id', 'name', 'balance', 'valid_from', 'valid_to',
- 'last_statement_balance', 'last_statement_at',
- 'last_statement_by_trigramme', 'last_statement_by_last_name',
- 'last_statement_by_first_name')
.select_related(
'statements'
'statements__by',
'statements__by__cofprofile__user')
.filter(pk=pk)
.order_by('statements__at')
- .last())
+ .values(
+ 'id', 'name', 'balance', 'valid_from', 'valid_to',
+ 'last_statement_balance', 'last_statement_at',
+ 'last_statement_by_trigramme', 'last_statement_by_last_name',
+ 'last_statement_by_first_name')
+ .last()
+ )
if data is None:
raise Http404
return JsonResponse(data)
@@ -1388,13 +1314,13 @@ def history_json(request):
# Construction de la requête (sur les opérations) pour le prefetch
queryset_prefetch = Operation.objects.select_related(
- 'canceled_by__trigramme', 'addcost_for__trigramme',
- 'article__name')
+ 'article', 'canceled_by', 'addcost_for')
# Construction de la requête principale
- opegroups = (OperationGroup.objects
- .prefetch_related(Prefetch('opes', queryset = queryset_prefetch))
- .select_related('on_acc__trigramme', 'valid_by__trigramme')
+ opegroups = (
+ OperationGroup.objects
+ .prefetch_related(Prefetch('opes', queryset=queryset_prefetch))
+ .select_related('on_acc', 'valid_by')
.order_by('at')
)
# Application des filtres
@@ -1477,6 +1403,9 @@ class SettingsList(TemplateView):
template_name = 'kfet/settings.html'
+config_list = permission_required('kfet.see_config')(SettingsList.as_view())
+
+
class SettingsUpdate(SuccessMessageMixin, FormView):
form_class = KFetConfigForm
template_name = 'kfet/settings_update.html'
@@ -1485,13 +1414,17 @@ class SettingsUpdate(SuccessMessageMixin, FormView):
def form_valid(self, form):
# Checking permission
- if not self.request.user.has_perm('kfet.change_settings'):
+ if not self.request.user.has_perm('kfet.change_config'):
form.add_error(None, 'Permission refusée')
return self.form_invalid(form)
form.save()
return super().form_valid(form)
+config_update = (
+ permission_required('kfet.change_config')(SettingsUpdate.as_view())
+)
+
# -----
# Transfer views
@@ -1776,7 +1709,7 @@ class InventoryRead(DetailView):
context_object_name = 'inventory'
def get_context_data(self, **kwargs):
- context = super(InventoryRead, self).get_context_data(**kwargs)
+ context = super().get_context_data(**kwargs)
inventoryarticles = (InventoryArticle.objects
.select_related('article', 'article__category')
.filter(inventory = self.object)
@@ -1794,7 +1727,7 @@ class OrderList(ListView):
context_object_name = 'orders'
def get_context_data(self, **kwargs):
- context = super(OrderList, self).get_context_data(**kwargs)
+ context = super().get_context_data(**kwargs)
context['suppliers'] = Supplier.objects.order_by('name')
return context
@@ -1908,9 +1841,12 @@ def order_create(request, pk):
else:
formset = cls_formset(initial=initial)
+ scale.label_fmt = "S-{rev_i}"
+
return render(request, 'kfet/order_create.html', {
'supplier': supplier,
'formset': formset,
+ 'scale': scale,
})
@@ -1920,7 +1856,7 @@ class OrderRead(DetailView):
context_object_name = 'order'
def get_context_data(self, **kwargs):
- context = super(OrderRead, self).get_context_data(**kwargs)
+ context = super().get_context_data(**kwargs)
orderarticles = (OrderArticle.objects
.select_related('article', 'article__category')
.filter(order=self.object)
@@ -2057,6 +1993,7 @@ def order_to_inventory(request, pk):
return render(request, 'kfet/order_to_inventory.html', {
'formset': formset,
+ 'order': order,
})
class SupplierUpdate(SuccessMessageMixin, UpdateView):
@@ -2073,7 +2010,7 @@ class SupplierUpdate(SuccessMessageMixin, UpdateView):
form.add_error(None, 'Permission refusée')
return self.form_invalid(form)
# Updating
- return super(SupplierUpdate, self).form_valid(form)
+ return super().form_valid(form)
# ==========
@@ -2318,7 +2255,7 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView):
)
context['charts'] = [{
- "color": "rgb(255, 99, 132)",
+ "color": "rgb(200, 20, 60)",
"label": "Balance",
"values": changes,
}]
@@ -2337,7 +2274,7 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView):
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
- return super(AccountStatBalance, self).dispatch(*args, **kwargs)
+ return super().dispatch(*args, **kwargs)
# ------------------------
@@ -2413,7 +2350,7 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView):
ventes = sum(ope['article_nb'] for ope in chunk)
nb_ventes.append(ventes)
- context['charts'] = [{"color": "rgb(255, 99, 132)",
+ context['charts'] = [{"color": "rgb(200, 20, 60)",
"label": "NB items achetés",
"values": nb_ventes}]
return context
@@ -2495,7 +2432,7 @@ class ArticleStatSales(ScaleMixin, JSONDetailView):
nb_accounts.append(sum_accounts)
nb_liq.append(sum_liq)
- context['charts'] = [{"color": "rgb(255, 99, 132)",
+ context['charts'] = [{"color": "rgb(200, 20, 60)",
"label": "Toutes consommations",
"values": nb_ventes},
{"color": "rgb(54, 162, 235)",
diff --git a/manage.py b/manage.py
old mode 100644
new mode 100755
index 7f4e79f6..094ec16f
--- a/manage.py
+++ b/manage.py
@@ -3,7 +3,7 @@ import os
import sys
if __name__ == "__main__":
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cof.settings")
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cof.settings.local")
from django.core.management import execute_from_command_line
diff --git a/provisioning/apache.conf b/provisioning/apache.conf
deleted file mode 100644
index db5bd602..00000000
--- a/provisioning/apache.conf
+++ /dev/null
@@ -1,39 +0,0 @@
-
- ServerName default
- DocumentRoot /var/www/html
-
- ProxyPreserveHost On
- ProxyRequests Off
- ProxyPass /static/ !
- ProxyPass /media/ !
- # Pour utiliser un sous-dossier (typiquement /gestion/), il faut faire a la
- # place des lignes suivantes:
- #
- # RequestHeader set Daphne-Root-Path /gestion
- # ProxyPass /gestion/ws/ ws://127.0.0.1:8001/ws/
- # ProxyPass /gestion http://127.0.0.1:8001/gestion
- # ProxyPassReverse /gestion http://127.0.0.1:8001/gestion
- #
- # Penser egalement a changer les /static/ et /media/ dans la config apache
- # ainsi que dans les settings django.
- ProxyPass /ws/ ws://127.0.0.1:8001/ws/
- ProxyPass / http://127.0.0.1:8001/
- ProxyPassReverse / http://127.0.0.1:8001/
-
- Alias /media /vagrant/media
- Alias /static /var/www/static
-
- Order deny,allow
- Allow from all
-
-
- Order deny,allow
- Allow from all
-
-
- ErrorLog ${APACHE_LOG_DIR}/error.log
- CustomLog ${APACHE_LOG_DIR}/access.log combined
-
-
-
-# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh
index 3dfb8b62..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.
@@ -8,76 +11,64 @@ DBNAME="cof_gestion"
DBPASSWD="4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4"
# Installation de paquets utiles
-apt-get update && apt-get install -y python3-pip python3-dev python3-venv \
- libmysqlclient-dev libjpeg-dev git redis-server python3-tk
-pip install -U pip
+apt-get update && apt-get upgrade -y
+apt-get install -y python3-pip python3-dev python3-venv libpq-dev postgresql \
+ postgresql-contrib libjpeg-dev nginx git redis-server
-# Configuration et installation de mysql. Le mot de passe root est le même que
-# le mot de passe pour l'utilisateur local - pour rappel, ceci est une instance
-# locale de développement.
-echo "mysql-server mysql-server/root_password password $DBPASSWD" | debconf-set-selections
-echo "mysql-server mysql-server/root_password_again password $DBPASSWD" | debconf-set-selections
+# Postgresql
+sudo -u postgres createdb $DBNAME
+sudo -u postgres createuser -SdR $DBUSER
+sudo -u postgres psql -c "ALTER USER $DBUSER WITH PASSWORD '$DBPASSWD';"
+sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DBNAME TO $DBUSER;"
-apt-get install -y mysql-server
-mysql -uroot -p$DBPASSWD -e "CREATE DATABASE $DBNAME; GRANT ALL PRIVILEGES ON $DBNAME.* TO '$DBUSER'@'localhost' IDENTIFIED BY '$DBPASSWD'"
-mysql -uroot -p$DBPASSWD -e "GRANT ALL PRIVILEGES ON test_$DBNAME.* TO '$DBUSER'@'localhost'"
-
-# Configuration de redis
+# Redis
REDIS_PASSWD="dummy"
redis-cli CONFIG SET requirepass $REDIS_PASSWD
redis-cli -a $REDIS_PASSWD CONFIG REWRITE
-# Installation et configuration d'Apache
-apt-get install -y apache2
-a2enmod proxy proxy_http proxy_wstunnel headers
-cp /vagrant/provisioning/apache.conf /etc/apache2/sites-available/gestiocof.conf
-a2ensite gestiocof
-a2dissite 000-default
-service apache2 restart
-mkdir /var/www/static
-chown -R ubuntu:www-data /var/www/static
+# Contenu statique
+mkdir -p /srv/gestiocof/media
+mkdir -p /srv/gestiocof/static
+chown -R ubuntu:www-data /srv/gestiocof
+
+# Nginx
+ln -s -f /vagrant/provisioning/nginx.conf /etc/nginx/sites-enabled/gestiocof.conf
+rm -f /etc/nginx/sites-enabled/default
+systemctl reload nginx
+
+# Environnement virtuel python
+sudo -H -u ubuntu python3 -m venv ~ubuntu/venv
+sudo -H -u ubuntu ~ubuntu/venv/bin/pip install -U pip
+sudo -H -u ubuntu ~ubuntu/venv/bin/pip install -r /vagrant/requirements-devel.txt
+
+# Préparation de Django
+cd /vagrant
+ln -s -f secret_example.py cof/settings/secret.py
+sudo -H -u ubuntu \
+ DJANGO_SETTINGS_MODULE='cof.settings.dev' \
+ bash -c ". ~/venv/bin/activate && bash provisioning/prepare_django.sh"
+/home/ubuntu/venv/bin/python manage.py collectstatic --noinput --settings cof.settings.dev
+
+# Installation du cron pour les mails de rappels
+sudo -H -u ubuntu crontab provisioning/cron.dev
+
+# Daphne + runworker
+cp /vagrant/provisioning/daphne.service /etc/systemd/system/daphne.service
+cp /vagrant/provisioning/worker.service /etc/systemd/system/worker.service
+systemctl enable daphne.service
+systemctl enable worker.service
+systemctl start daphne.service
+systemctl start worker.service
# Mise en place du .bash_profile pour tout configurer lors du `vagrant ssh`
cat >> ~ubuntu/.bashrc <> /vagrant/rappels.log ; python /vagrant/manage.py sendrappels >> /vagrant/rappels.log 2>&1
-*/5 * * * * python /vagrant/manage.py manage_revente >> /vagrant/reventes.log 2>&1
+19 */12 * * * date >> /vagrant/rappels.log ; /ubuntu/home/venv/bin/python /vagrant/manage.py sendrappels >> /vagrant/rappels.log 2>&1
+*/5 * * * * /ubuntu/home/venv/bin/python /vagrant/manage.py manage_revente >> /vagrant/reventes.log 2>&1
diff --git a/provisioning/cron.md b/provisioning/cron.md
index 840a8716..7aff775b 100644
--- a/provisioning/cron.md
+++ b/provisioning/cron.md
@@ -9,9 +9,9 @@ envoie les mails de rappels des spectacles à venir (sauf s'ils ont déjà été
envoyés).
- Un fois toutes les 12 heures me semble bien.
-- Penser à utiliser le bon executable python (virtualenvs) et les bonnes
- variables d'environnement si besoin.
-- Garde les logs peut être une bonne idée.
+- Penser à utiliser le bon executable python (virtualenvs) et le bon fichier de
+ settings pour Django.
+- Garder les logs peut être une bonne idée.
Exemple : voir le fichier `provisioning/cron.dev`.
diff --git a/provisioning/daphne.service b/provisioning/daphne.service
new file mode 100644
index 00000000..41327ce5
--- /dev/null
+++ b/provisioning/daphne.service
@@ -0,0 +1,16 @@
+Description="GestioCOF"
+After=syslog.target
+After=network.target
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+TimeoutSec=300
+WorkingDirectory=/vagrant
+Environment="DJANGO_SETTINGS_MODULE=cof.settings.dev"
+ExecStart=/home/ubuntu/venv/bin/daphne -u /srv/gestiocof/gestiocof.sock \
+ cof.asgi:channel_layer
+
+[Install]
+WantedBy=multi-user.target
diff --git a/provisioning/nginx.conf b/provisioning/nginx.conf
new file mode 100644
index 00000000..015e1712
--- /dev/null
+++ b/provisioning/nginx.conf
@@ -0,0 +1,56 @@
+upstream gestiocof {
+ # Daphne listens on a unix socket
+ server unix:/srv/gestiocof/gestiocof.sock;
+}
+
+server {
+ listen 80;
+
+ server_name localhost;
+ root /srv/gestiocof/;
+
+ # / → /gestion/
+ # /gestion → /gestion/
+ rewrite ^/$ /gestion/;
+ rewrite ^/gestion$ /gestion/;
+
+ # Static files
+ location /static/ {
+ access_log off;
+ add_header Cache-Control "public";
+ expires 7d;
+ }
+
+ # Uploaded media
+ location /media/ {
+ access_log off;
+ add_header Cache-Control "public";
+ expires 7d;
+ }
+
+ location /gestion/ {
+ # A copy-paste of what we have in production
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
+ proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
+ proxy_set_header X-SSL-Client-S-DN $ssl_client_s_dn;
+ proxy_set_header Daphne-Root-Path /gestion;
+
+ location /gestion/ws/ {
+ # See http://nginx.org/en/docs/http/websocket.html
+ proxy_buffering off;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ proxy_pass http://gestiocof/ws/;
+ }
+
+ location /gestion/ {
+ proxy_pass http://gestiocof;
+ }
+ }
+}
diff --git a/provisioning/prepare_django.sh b/provisioning/prepare_django.sh
index 4ec1a70f..891108e8 100644
--- a/provisioning/prepare_django.sh
+++ b/provisioning/prepare_django.sh
@@ -1,9 +1,9 @@
#!/bin/bash
-# Doit être lancé par bootstrap.sh
-source ~/venv/bin/activate
+# Stop if an error is encountered.
+set -e
+
python manage.py migrate
python manage.py loaddata gestion sites articles
python manage.py loaddevdata
python manage.py syncmails
-python manage.py collectstatic --noinput
diff --git a/provisioning/supervisor.conf b/provisioning/supervisor.conf
deleted file mode 100644
index 5fe3c22b..00000000
--- a/provisioning/supervisor.conf
+++ /dev/null
@@ -1,20 +0,0 @@
-[program:worker]
-command=/home/ubuntu/venv/bin/python /vagrant/manage.py runworker
-directory=/vagrant/
-user=ubuntu
-environment=DBUSER={DBUSER},DBNAME={DBNAME},DBPASSWD={DBPASSWD},DJANGO_SETTINGS_MODULE="cof.settings.dev"
-autostart=true
-autorestart=true
-redirect_stderr=true
-stopasgroup=true
-redirect_stderr=true
-
-[program:interface]
-command=/home/ubuntu/venv/bin/daphne -b 127.0.0.1 -p 8001 cof.asgi:channel_layer
-environment=DBUSER={DBUSER},DBNAME={DBNAME},DBPASSWD={DBPASSWD},DJANGO_SETTINGS_MODULE="cof.settings.dev"
-directory=/vagrant/
-redirect_stderr=true
-autostart=true
-autorestart=true
-stopasgroup=true
-user=ubuntu
diff --git a/provisioning/worker.service b/provisioning/worker.service
new file mode 100644
index 00000000..42836cfe
--- /dev/null
+++ b/provisioning/worker.service
@@ -0,0 +1,16 @@
+[Unit]
+Description="GestioCOF"
+After=syslog.target
+After=network.target
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+TimeoutSec=300
+WorkingDirectory=/vagrant
+Environment="DJANGO_SETTINGS_MODULE=cof.settings.dev"
+ExecStart=/home/ubuntu/venv/bin/python manage.py runworker
+
+[Install]
+WantedBy=multi-user.target
diff --git a/requirements.txt b/requirements.txt
index 29cb1bac..6310cecd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,26 +1,30 @@
configparser==3.5.0
-Django==1.8.*
-django-autocomplete-light==2.3.3
+Django==1.11.*
+django-autocomplete-light==3.1.3
django-autoslug==1.9.3
-django-cas-ng==3.5.5
+django-cas-ng==3.5.7
django-djconfig==0.5.3
-django-grappelli==2.8.1
-django-recaptcha==1.0.5
-mysqlclient==1.3.7
-Pillow==3.3.0
-six==1.10.0
-unicodecsv==0.14.1
-icalendar==3.10
-django-bootstrap-form==3.2.1
+django-recaptcha==1.4.0
+django-redis-cache==1.7.1
+icalendar
+psycopg2
+Pillow
+unicodecsv
+django-bootstrap-form==3.3
asgiref==1.1.1
-daphne==1.2.0
+daphne==1.3.0
asgi-redis==1.3.0
statistics==1.0.3.5
-future==0.15.2
django-widget-tweaks==1.4.1
git+https://git.eleves.ens.fr/cof-geek/django_custommail.git#egg=django_custommail
ldap3
-channels==1.1.3
+channels==1.1.5
python-dateutil
numpy==1.12.1
matplotlib==2.0.0
+wagtail==1.10.*
+wagtailmenus==2.2.*
+django-cors-headers==2.2.0
+
+# Production tools
+wheel
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('