From 85caa6b0581b76b8fb6aa92fd01aaffbec9e9bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 3 Apr 2017 20:32:16 +0200 Subject: [PATCH 01/90] Use django-djconfig for kfet app. Old configuration(/settings), based on Settings model, system is deleted: SettingsForm, Settings. New system use `django-djconfig` module. - `kfet.config` module provides `kfet_config` to access configuration concerning kfet app. - Views, forms, models, etc now use this object to retrieve conf values. - Views no longer add config values to context, instead templates use `kfet_config` provided by a new context_processor. - Enhance list and update views of settings. - Fix: settings can directly be used without having to visit a specific page... Misc - Delete some py2/3 imports - Delete unused imports in kfet.models and kfet.views - Some PEP8 compliance --- cof/settings_dev.py | 4 + kfet/apps.py | 6 ++ kfet/config.py | 25 +++++ kfet/context_processors.py | 11 +- kfet/forms.py | 64 ++++++----- kfet/migrations/0051_delete_settings.py | 52 +++++++++ kfet/models.py | 124 +--------------------- kfet/templates/kfet/account_negative.html | 4 +- kfet/templates/kfet/account_read.html | 2 +- kfet/templates/kfet/history.html | 2 +- kfet/templates/kfet/settings.html | 52 ++++++--- kfet/templates/kfet/settings_update.html | 25 +++-- kfet/templatetags/kfet_tags.py | 5 +- kfet/urls.py | 2 +- kfet/views.py | 95 +++++++++-------- requirements.txt | 1 + 16 files changed, 247 insertions(+), 227 deletions(-) create mode 100644 kfet/config.py create mode 100644 kfet/migrations/0051_delete_settings.py diff --git a/cof/settings_dev.py b/cof/settings_dev.py index 18aadaad..b04165c8 100644 --- a/cof/settings_dev.py +++ b/cof/settings_dev.py @@ -47,6 +47,7 @@ INSTALLED_APPS = ( 'channels', 'widget_tweaks', 'custommail', + 'djconfig', ) MIDDLEWARE_CLASSES = ( @@ -60,6 +61,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', + 'djconfig.middleware.DjConfigMiddleware', ) ROOT_URLCONF = 'cof.urls' @@ -78,8 +80,10 @@ TEMPLATES = [ 'django.core.context_processors.i18n', 'django.core.context_processors.media', 'django.core.context_processors.static', + 'djconfig.context_processors.config', 'gestioncof.shared.context_processor', 'kfet.context_processors.auth', + 'kfet.context_processors.config', ], }, }, diff --git a/kfet/apps.py b/kfet/apps.py index 29f9f98e..3dd2c0e8 100644 --- a/kfet/apps.py +++ b/kfet/apps.py @@ -12,3 +12,9 @@ class KFetConfig(AppConfig): def ready(self): import kfet.signals + self.register_config() + + def register_config(self): + import djconfig + from kfet.forms import KFetConfigForm + djconfig.register(KFetConfigForm) diff --git a/kfet/config.py b/kfet/config.py new file mode 100644 index 00000000..deb12504 --- /dev/null +++ b/kfet/config.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +from djconfig import config + + +class KFetConfig(object): + """kfet app configuration. + + Enhance dependency with backend used to store config. + Usable after DjConfig middleware was called. + + """ + prefix = 'kfet_' + + def __getattr__(self, key): + dj_key = '{}{}'.format(self.prefix, key) + return getattr(config, dj_key) + + def list(self): + from kfet.forms import KFetConfigForm + return [(field.label, getattr(config, name), ) + for name, field in KFetConfigForm.base_fields.items()] + + +kfet_config = KFetConfig() diff --git a/kfet/context_processors.py b/kfet/context_processors.py index ef4f2e64..4c7b4fe4 100644 --- a/kfet/context_processors.py +++ b/kfet/context_processors.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * - from django.contrib.auth.context_processors import PermWrapper +from kfet.config import kfet_config + + def auth(request): if hasattr(request, 'real_user'): return { @@ -13,3 +12,7 @@ def auth(request): 'perms': PermWrapper(request.real_user), } return {} + + +def config(request): + return {'kfet_config': kfet_config} diff --git a/kfet/forms.py b/kfet/forms.py index 0fc02dd3..b6335fb8 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- +from datetime import timedelta from decimal import Decimal + from django import forms from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator @@ -8,12 +10,16 @@ from django.contrib.auth.models import User, Group, Permission from django.contrib.contenttypes.models import ContentType from django.forms import modelformset_factory from django.utils import timezone + +from djconfig.forms import ConfigForm + from kfet.models import ( Account, Checkout, Article, OperationGroup, Operation, - CheckoutStatement, ArticleCategory, Settings, AccountNegative, Transfer, + CheckoutStatement, ArticleCategory, AccountNegative, Transfer, TransferGroup, Supplier) from gestioncof.models import CofProfile + # ----- # Widgets # ----- @@ -379,40 +385,42 @@ class AddcostForm(forms.Form): self.cleaned_data['amount'] = 0 super(AddcostForm, self).clean() + # ----- # Settings forms # ----- -class SettingsForm(forms.ModelForm): - class Meta: - model = Settings - fields = ['value_decimal', 'value_account', 'value_duration'] - def clean(self): - name = self.instance.name - value_decimal = self.cleaned_data.get('value_decimal') - value_account = self.cleaned_data.get('value_account') - value_duration = self.cleaned_data.get('value_duration') +class KFetConfigForm(ConfigForm): - type_decimal = ['SUBVENTION_COF', 'ADDCOST_AMOUNT', 'OVERDRAFT_AMOUNT'] - type_account = ['ADDCOST_FOR'] - type_duration = ['OVERDRAFT_DURATION', 'CANCEL_DURATION'] + kfet_subvention_cof = forms.DecimalField( + label='Subvention COF', initial=Decimal('25'), + max_digits=6, decimal_places=2, + ) + kfet_addcost_amount = forms.DecimalField( + label='Montant de la majoration', initial=Decimal('0'), required=False, + max_digits=6, decimal_places=2, + ) + kfet_addcost_for = forms.ModelChoiceField( + label='Destinataire de la majoration', initial=None, required=False, + help_text='Laissez vide pour désactiver la majoration', + queryset=(Account.objects + .select_related('cofprofile', 'cofprofile__user') + .all()), + ) + kfet_overdraft_duration = forms.DurationField( + label='Durée du découvert autorisé par défaut', + initial=timedelta(days=1), + ) + kfet_overdraft_amount = forms.DecimalField( + label='Montant du découvert autorisé par défaut', initial=Decimal('20'), + max_digits=6, decimal_places=2, + ) + kfet_cancel_duration = forms.DurationField( + label='Durée pour annuler une commande sans mot de passe', + initial=timedelta(minutes=5), + ) - self.cleaned_data['name'] = name - if name in type_decimal: - if not value_decimal: - raise ValidationError('Renseignez une valeur décimale') - self.cleaned_data['value_account'] = None - self.cleaned_data['value_duration'] = None - elif name in type_account: - self.cleaned_data['value_decimal'] = None - self.cleaned_data['value_duration'] = None - elif name in type_duration: - if not value_duration: - raise ValidationError('Renseignez une durée') - self.cleaned_data['value_decimal'] = None - self.cleaned_data['value_account'] = None - super(SettingsForm, self).clean() class FilterHistoryForm(forms.Form): checkouts = forms.ModelMultipleChoiceField(queryset = Checkout.objects.all()) diff --git a/kfet/migrations/0051_delete_settings.py b/kfet/migrations/0051_delete_settings.py new file mode 100644 index 00000000..5addad28 --- /dev/null +++ b/kfet/migrations/0051_delete_settings.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + +from kfet.forms import KFetConfigForm + + +def adapt_settings(apps, schema_editor): + Settings = apps.get_model('kfet', 'Settings') + db_alias = schema_editor.connection.alias + obj = Settings.objects.using(db_alias) + + cfg = {} + + def try_get(new, old, type_field): + try: + value = getattr(obj.get(name=old), type_field) + cfg[new] = value + except Settings.DoesNotExist: + pass + + try_get('kfet_subvention_cof', 'SUBVENTION_COF', 'value_decimal') + try_get('kfet_addcost_amount', 'ADDCOST_AMOUNT', 'value_decimal') + try_get('kfet_addcost_for', 'ADDCOST_FOR', 'value_account') + try_get('kfet_overdraft_duration', 'OVERDRAFT_DURATION', 'value_duration') + try_get('kfet_overdraft_amount', 'OVERDRAFT_AMOUNT', 'value_decimal') + try_get('kfet_cancel_duration', 'CANCEL_DURATION', 'value_duration') + + cfg_form = KFetConfigForm(initial=cfg) + if cfg_form.is_valid(): + cfg_form.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0050_remove_checkout'), + ('djconfig', '0001_initial'), + ] + + operations = [ + migrations.RunPython(adapt_settings), + migrations.RemoveField( + model_name='settings', + name='value_account', + ), + migrations.DeleteModel( + name='Settings', + ), + ] diff --git a/kfet/models.py b/kfet/models.py index c039ab06..de871f0c 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -1,12 +1,7 @@ # -*- coding: utf-8 -*- -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * - from django.db import models from django.core.urlresolvers import reverse -from django.core.exceptions import PermissionDenied, ValidationError from django.core.validators import RegexValidator from django.contrib.auth.models import User from gestioncof.models import CofProfile @@ -15,11 +10,12 @@ from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.db import transaction from django.db.models import F -from django.core.cache import cache -from datetime import date, timedelta +from datetime import date import re import hashlib +from kfet.config import kfet_config + def choices_length(choices): return reduce(lambda m, choice: max(m, len(choice[0])), choices, 0) @@ -113,8 +109,8 @@ class Account(models.Model): return data def perms_to_perform_operation(self, amount): - overdraft_duration_max = Settings.OVERDRAFT_DURATION() - overdraft_amount_max = Settings.OVERDRAFT_AMOUNT() + overdraft_duration_max = kfet_config.overdraft_duration + overdraft_amount_max = kfet_config.overdraft_amount perms = set() stop_ope = False # Checking is cash account @@ -620,116 +616,6 @@ class GlobalPermissions(models.Model): ('special_add_account', "Créer un compte avec une balance initiale") ) -class Settings(models.Model): - name = models.CharField( - max_length = 45, - unique = True, - db_index = True) - value_decimal = models.DecimalField( - max_digits = 6, decimal_places = 2, - blank = True, null = True, default = None) - value_account = models.ForeignKey( - Account, on_delete = models.PROTECT, - blank = True, null = True, default = None) - value_duration = models.DurationField( - blank = True, null = True, default = None) - - @staticmethod - def setting_inst(name): - return Settings.objects.get(name=name) - - @staticmethod - def SUBVENTION_COF(): - subvention_cof = cache.get('SUBVENTION_COF') - if subvention_cof: - return subvention_cof - try: - subvention_cof = Settings.setting_inst("SUBVENTION_COF").value_decimal - except Settings.DoesNotExist: - subvention_cof = 0 - cache.set('SUBVENTION_COF', subvention_cof) - return subvention_cof - - @staticmethod - def ADDCOST_AMOUNT(): - try: - return Settings.setting_inst("ADDCOST_AMOUNT").value_decimal - except Settings.DoesNotExist: - return 0 - - @staticmethod - def ADDCOST_FOR(): - try: - return Settings.setting_inst("ADDCOST_FOR").value_account - except Settings.DoesNotExist: - return None; - - @staticmethod - def OVERDRAFT_DURATION(): - overdraft_duration = cache.get('OVERDRAFT_DURATION') - if overdraft_duration: - return overdraft_duration - try: - overdraft_duration = Settings.setting_inst("OVERDRAFT_DURATION").value_duration - except Settings.DoesNotExist: - overdraft_duration = timedelta() - cache.set('OVERDRAFT_DURATION', overdraft_duration) - return overdraft_duration - - @staticmethod - def OVERDRAFT_AMOUNT(): - overdraft_amount = cache.get('OVERDRAFT_AMOUNT') - if overdraft_amount: - return overdraft_amount - try: - overdraft_amount = Settings.setting_inst("OVERDRAFT_AMOUNT").value_decimal - except Settings.DoesNotExist: - overdraft_amount = 0 - cache.set('OVERDRAFT_AMOUNT', overdraft_amount) - return overdraft_amount - - @staticmethod - def CANCEL_DURATION(): - cancel_duration = cache.get('CANCEL_DURATION') - if cancel_duration: - return cancel_duration - try: - cancel_duration = Settings.setting_inst("CANCEL_DURATION").value_duration - except Settings.DoesNotExist: - cancel_duration = timedelta() - cache.set('CANCEL_DURATION', cancel_duration) - return cancel_duration - - @staticmethod - def create_missing(): - s, created = Settings.objects.get_or_create(name='SUBVENTION_COF') - if created: - s.value_decimal = 25 - s.save() - s, created = Settings.objects.get_or_create(name='ADDCOST_AMOUNT') - if created: - s.value_decimal = 0.5 - s.save() - s, created = Settings.objects.get_or_create(name='ADDCOST_FOR') - s, created = Settings.objects.get_or_create(name='OVERDRAFT_DURATION') - if created: - s.value_duration = timedelta(days=1) # 24h - s.save() - s, created = Settings.objects.get_or_create(name='OVERDRAFT_AMOUNT') - if created: - s.value_decimal = 20 - s.save() - s, created = Settings.objects.get_or_create(name='CANCEL_DURATION') - if created: - s.value_duration = timedelta(minutes=5) # 5min - s.save() - - @staticmethod - def empty_cache(): - cache.delete_many([ - 'SUBVENTION_COF', 'OVERDRAFT_DURATION', 'OVERDRAFT_AMOUNT', - 'CANCEL_DURATION', 'ADDCOST_AMOUNT', 'ADDCOST_FOR', - ]) class GenericTeamToken(models.Model): token = models.CharField(max_length = 50, unique = True) diff --git a/kfet/templates/kfet/account_negative.html b/kfet/templates/kfet/account_negative.html index 5f77b8f0..af4366ed 100644 --- a/kfet/templates/kfet/account_negative.html +++ b/kfet/templates/kfet/account_negative.html @@ -16,8 +16,8 @@
Découvert autorisé par défaut
-
Montant: {{ settings.overdraft_amount }}€
-
Pendant: {{ settings.overdraft_duration }}
+
Montant: {{ kfet_config.overdraft_amount }}€
+
Pendant: {{ kfet_config.overdraft_duration }}
{% if perms.kfet.change_settings %} diff --git a/kfet/templates/kfet/account_read.html b/kfet/templates/kfet/account_read.html index 50ab7f20..5c627aad 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -95,7 +95,7 @@ - {% if account.user == request.user %} @@ -18,11 +17,11 @@ $(document).ready(function() { var stat_last = new StatsGroup( "{% url 'kfet.account.stat.operation.list' trigramme=account.trigramme %}", - $("#stat_last"), + $("#stat_last") ); var stat_balance = new StatsGroup( "{% url 'kfet.account.stat.balance.list' trigramme=account.trigramme %}", - $("#stat_balance"), + $("#stat_balance") ); }); From 1e18c4043e4593849ebd982340ea26f04f02a363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 9 Apr 2017 15:47:16 +0200 Subject: [PATCH 35/90] Use last channels & co versions - Use last official releases of channels, asgiref, daphne and asgi-redis packages. - Customization of JsonWebsocketConsumer is now in kfet app through a custom class (and so, doesn't require anymore a forked version of channels). - Clean kfet consumers code. --- kfet/consumers.py | 27 +++++++++++---------------- requirements.txt | 8 ++++---- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/kfet/consumers.py b/kfet/consumers.py index dcd69bdf..6e9dc6ca 100644 --- a/kfet/consumers.py +++ b/kfet/consumers.py @@ -1,26 +1,21 @@ # -*- coding: utf-8 -*- -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * +from django.core.serializers.json import json, DjangoJSONEncoder -from channels import Group from channels.generic.websockets import JsonWebsocketConsumer -class KPsul(JsonWebsocketConsumer): - # Set to True if you want them, else leave out - strict_ordering = False - slight_ordering = False +class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer): + """Custom Json Websocket Consumer. - def connection_groups(self, **kwargs): - return ['kfet.kpsul'] + Encode to JSON with DjangoJSONEncoder. - def connect(self, message, **kwargs): - pass + """ - def receive(self, content, **kwargs): - pass + @classmethod + def encode_json(cls, content): + return json.dumps(content, cls=DjangoJSONEncoder) - def disconnect(self, message, **kwargs): - pass + +class KPsul(DjangoJsonWebsocketConsumer): + groups = ['kfet.kpsul'] diff --git a/requirements.txt b/requirements.txt index ce081588..990fba3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,13 +11,13 @@ six==1.10.0 unicodecsv==0.14.1 icalendar==3.10 django-bootstrap-form==3.2.1 -asgiref==0.14.0 -daphne==0.14.3 -asgi-redis==0.14.0 +asgiref==1.1.1 +daphne==1.2.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 -git+https://github.com/Aureplop/channels.git#egg=channels +channels==1.1.3 python-dateutil From 029d59e615adba19c61f2cd2be72a6f2d71cc23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 9 Apr 2017 16:10:27 +0200 Subject: [PATCH 36/90] Enable authentication on KPsul websocket. - PermConsumerMixin allows checking permissions on connection to a consumer. - KPsul consumer uses this mixin to check if connecting user has the permission `kfet.is_team`. Fixes #67. --- kfet/consumers.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/kfet/consumers.py b/kfet/consumers.py index 6e9dc6ca..ee096368 100644 --- a/kfet/consumers.py +++ b/kfet/consumers.py @@ -17,5 +17,25 @@ class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer): return json.dumps(content, cls=DjangoJSONEncoder) -class KPsul(DjangoJsonWebsocketConsumer): +class PermConsumerMixin(object): + """Add support to check permissions on Consumers. + + Attributes: + perms_connect (list): Required permissions to connect to this + consumer. + + """ + 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() + + +class KPsul(PermConsumerMixin, DjangoJsonWebsocketConsumer): groups = ['kfet.kpsul'] + perms_connect = ['kfet.is_team'] From 8870b5ace2cad03e9d44e8a730ac6b37a32660f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 9 Apr 2017 17:37:15 +0200 Subject: [PATCH 37/90] Fewer queries on poll view --- gestioncof/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gestioncof/views.py b/gestioncof/views.py index 944d9dc2..457a99c4 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -94,7 +94,10 @@ def logout(request): @login_required def survey(request, survey_id): - survey = get_object_or_404(Survey, id=survey_id) + survey = get_object_or_404( + Survey.objects.prefetch_related('questions', 'questions__answers'), + id=survey_id, + ) if not survey.survey_open or survey.old: raise Http404 success = False From 3dc91e30bd798cdd6c18c928727f5b8da218ea9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 9 Apr 2017 17:51:40 +0200 Subject: [PATCH 38/90] Fewer requests on petit cours list management. --- gestioncof/petits_cours_views.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/gestioncof/petits_cours_views.py b/gestioncof/petits_cours_views.py index 332e156c..ca0b55af 100644 --- a/gestioncof/petits_cours_views.py +++ b/gestioncof/petits_cours_views.py @@ -24,13 +24,14 @@ from gestioncof.shared import lock_table, unlock_tables class DemandeListView(ListView): - model = PetitCoursDemande + queryset = ( + PetitCoursDemande.objects + .prefetch_related('matieres') + .order_by('traitee', '-id') + ) template_name = "petits_cours_demandes_list.html" paginate_by = 20 - def get_queryset(self): - return PetitCoursDemande.objects.order_by('traitee', '-id').all() - class DemandeDetailView(DetailView): model = PetitCoursDemande From 6ce2f178bf29c4c87a14de858037e3b66b27d4bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 9 Apr 2017 17:57:11 +0200 Subject: [PATCH 39/90] Fewer requests on petit cours details management. --- gestioncof/petits_cours_views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gestioncof/petits_cours_views.py b/gestioncof/petits_cours_views.py index ca0b55af..3fa0dc57 100644 --- a/gestioncof/petits_cours_views.py +++ b/gestioncof/petits_cours_views.py @@ -35,6 +35,11 @@ class DemandeListView(ListView): class DemandeDetailView(DetailView): model = PetitCoursDemande + queryset = ( + PetitCoursDemande.objects + .prefetch_related('petitcoursattribution_set', + 'matieres') + ) template_name = "gestioncof/details_demande_petit_cours.html" context_object_name = "demande" From c228416809149c7ed39fd42f9d3309b66a127167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 10 Apr 2017 11:36:06 +0200 Subject: [PATCH 40/90] =?UTF-8?q?Subvention=20->=20R=C3=A9duction=20+=20un?= =?UTF-8?q?its=20for=20kfet=5Fconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kfet_config gives "reduction_cof" as editable instead of "subvention_cof" - this last one can still be accessed via kfet_config (computed from new "reduction_cof" - add units to numeric values of kfet_config form --- kfet/config.py | 5 +++++ kfet/forms.py | 14 +++++++++----- kfet/migrations/0054_delete_settings.py | 8 +++++++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/kfet/config.py b/kfet/config.py index 5023e8b0..76da5a79 100644 --- a/kfet/config.py +++ b/kfet/config.py @@ -16,6 +16,11 @@ class KFetConfig(object): prefix = 'kfet_' def __getattr__(self, key): + if key == 'subvention_cof': + # Allows accessing to the reduction as a subvention + # Other reason: backward compatibility + reduction_mult = 1 - self.reduction_cof/100 + return (1/reduction_mult - 1) * 100 return getattr(config, self._get_dj_key(key)) def list(self): diff --git a/kfet/forms.py b/kfet/forms.py index 9b098b75..f89b8f08 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -403,17 +403,20 @@ class AddcostForm(forms.Form): class KFetConfigForm(ConfigForm): - kfet_subvention_cof = forms.DecimalField( - label='Subvention COF', initial=Decimal('25'), + kfet_reduction_cof = forms.DecimalField( + label='Réduction COF', initial=Decimal('20'), max_digits=6, decimal_places=2, + help_text="Réduction, à donner en pourcentage, appliquée lors d'un " + "achat par un-e membre du COF sur le montant en euros.", ) kfet_addcost_amount = forms.DecimalField( - label='Montant de la majoration', initial=Decimal('0'), required=False, + label='Montant de la majoration (en €)', initial=Decimal('0'), + required=False, max_digits=6, decimal_places=2, ) kfet_addcost_for = forms.ModelChoiceField( label='Destinataire de la majoration', initial=None, required=False, - help_text='Laissez vide pour désactiver la majoration', + help_text='Laissez vide pour désactiver la majoration.', queryset=(Account.objects .select_related('cofprofile', 'cofprofile__user') .all()), @@ -423,7 +426,8 @@ class KFetConfigForm(ConfigForm): initial=timedelta(days=1), ) kfet_overdraft_amount = forms.DecimalField( - label='Montant du découvert autorisé par défaut', initial=Decimal('20'), + label='Montant du découvert autorisé par défaut (en €)', + initial=Decimal('20'), max_digits=6, decimal_places=2, ) kfet_cancel_duration = forms.DurationField( diff --git a/kfet/migrations/0054_delete_settings.py b/kfet/migrations/0054_delete_settings.py index 7a0b1ab8..80ee1d24 100644 --- a/kfet/migrations/0054_delete_settings.py +++ b/kfet/migrations/0054_delete_settings.py @@ -21,7 +21,13 @@ def adapt_settings(apps, schema_editor): except Settings.DoesNotExist: pass - try_get('kfet_subvention_cof', 'SUBVENTION_COF', 'value_decimal') + try: + subvention = obj.get(name='SUBVENTION_COF').value_decimal + subvention_mult = 1 + subvention/100 + reduction = (1 - 1/subvention_mult) * 100 + cfg['kfet_reduction_cof'] = reduction + except Settings.DoesNotExist: + pass try_get('kfet_addcost_amount', 'ADDCOST_AMOUNT', 'value_decimal') try_get('kfet_addcost_for', 'ADDCOST_FOR', 'value_account') try_get('kfet_overdraft_duration', 'OVERDRAFT_DURATION', 'value_duration') From 5d6012b6bddf44189a1709c85120698117146f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 10 Apr 2017 11:52:57 +0200 Subject: [PATCH 41/90] Fix kfet tests - and add test for `kfet_config.subvention_cof` --- kfet/tests/test_config.py | 13 ++++++++++--- kfet/{tests.py => tests/test_statistic.py} | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) rename kfet/{tests.py => tests/test_statistic.py} (97%) diff --git a/kfet/tests/test_config.py b/kfet/tests/test_config.py index 79781c0d..03c9cf3c 100644 --- a/kfet/tests/test_config.py +++ b/kfet/tests/test_config.py @@ -22,13 +22,20 @@ class ConfigTest(TestCase): def test_get(self): self.assertTrue(hasattr(kfet_config, 'subvention_cof')) + def test_subvention_cof(self): + reduction_cof = Decimal('20') + subvention_cof = Decimal('25') + kfet_config.set(reduction_cof=reduction_cof) + + self.assertEqual(kfet_config.subvention_cof, subvention_cof) + def test_set_decimal(self): """Test field of decimal type.""" - subvention_cof = Decimal('10') + reduction_cof = Decimal('10') # IUT - kfet_config.set(subvention_cof=subvention_cof) + kfet_config.set(reduction_cof=reduction_cof) # check - self.assertEqual(kfet_config.subvention_cof, subvention_cof) + self.assertEqual(kfet_config.reduction_cof, reduction_cof) def test_set_modelinstance(self): """Test field of model instance type.""" diff --git a/kfet/tests.py b/kfet/tests/test_statistic.py similarity index 97% rename from kfet/tests.py rename to kfet/tests/test_statistic.py index 991b2545..4fb0785d 100644 --- a/kfet/tests.py +++ b/kfet/tests/test_statistic.py @@ -5,7 +5,7 @@ from unittest.mock import patch from django.test import TestCase, Client from django.contrib.auth.models import User, Permission -from .models import Account, Article, ArticleCategory +from kfet.models import Account, Article, ArticleCategory class TestStats(TestCase): From a5fb162aaf412870e86390ca49dd059673308695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Mon, 10 Apr 2017 22:18:00 +0100 Subject: [PATCH 42/90] New organisation of settings files We reproduce what has been done here: https://github.com/dissemin/dissemin The following files can be found under `cof/settings/` - `common.py`: the settings that are shared by all the environments we have + the secrets (see below). - `dev.py`: the settings used by the vagrant VM for local development. - `prod.py`: the production settings (for both www.cof.ens.fr and dev.cof.ens.fr) There is also a notion of "secrets". Some settings like the `SECRET_KEY` or the database's credentials are loaded from an untracked files called `secret.py` in the same directory. This secrets are loaded by the common settings file. --- cof/settings/.gitignore | 1 + cof/{settings_dev.py => settings/common.py} | 93 +++++++-------------- cof/settings/dev.py | 52 ++++++++++++ cof/settings/prod.py | 26 ++++++ cof/settings/secret_example.py | 4 + 5 files changed, 113 insertions(+), 63 deletions(-) create mode 100644 cof/settings/.gitignore rename cof/{settings_dev.py => settings/common.py} (63%) create mode 100644 cof/settings/dev.py create mode 100644 cof/settings/prod.py create mode 100644 cof/settings/secret_example.py diff --git a/cof/settings/.gitignore b/cof/settings/.gitignore new file mode 100644 index 00000000..21425062 --- /dev/null +++ b/cof/settings/.gitignore @@ -0,0 +1 @@ +secret.py diff --git a/cof/settings_dev.py b/cof/settings/common.py similarity index 63% rename from cof/settings_dev.py rename to cof/settings/common.py index b04165c8..4a6f9a12 100644 --- a/cof/settings_dev.py +++ b/cof/settings/common.py @@ -1,32 +1,40 @@ # -*- coding: utf-8 -*- """ -Django settings for cof project. +Django common settings for cof project. -For more information on this file, see -https://docs.djangoproject.com/en/1.8/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.8/ref/settings/ +Everything which is supposed to be identical between the production server and +the local development serveur should be here. """ -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) import os -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# Database credentials +try: + from .secret import DBNAME, DBUSER, DBPASSWD +except ImportError: + # On the local development VM, theses credentials are in the environment + DBNAME = os.environ["DBNAME"] + DBUSER = os.environ["DBUSER"] + DBPASSWD = os.environ["DBPASSWD"] +except KeyError: + raise RuntimeError("Secrets missing") -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +# Other secrets +try: + from .secret import ( + SECRET_KEY, RECAPTCHA_PUBLIC_KEY, RECAPTCHA_PRIVATE_KEY, ADMINS + ) +except ImportError: + raise RuntimeError("Secrets missing") -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'q()(zn4m63i%5cp4)f+ww4-28_w+ly3q9=6imw2ciu&_(5_4ah' +BASE_DIR = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True # Application definition -INSTALLED_APPS = ( +INSTALLED_APPS = [ 'gestioncof', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -41,17 +49,15 @@ INSTALLED_APPS = ( 'autocomplete_light', 'captcha', 'django_cas_ng', - 'debug_toolbar', 'bootstrapform', 'kfet', 'channels', 'widget_tweaks', 'custommail', 'djconfig', -) +] -MIDDLEWARE_CLASSES = ( - 'debug_toolbar.middleware.DebugToolbarMiddleware', +MIDDLEWARE_CLASSES = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -62,7 +68,7 @@ MIDDLEWARE_CLASSES = ( 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', 'djconfig.middleware.DjConfigMiddleware', -) +] ROOT_URLCONF = 'cof.urls' @@ -73,7 +79,6 @@ TEMPLATES = [ 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ - 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', @@ -89,17 +94,12 @@ TEMPLATES = [ }, ] -# WSGI_APPLICATION = 'cof.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/1.8/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', - 'NAME': os.environ['DBNAME'], - 'USER': os.environ['DBUSER'], - 'PASSWORD': os.environ['DBPASSWD'], + 'NAME': DBNAME, + 'USER': DBUSER, + 'PASSWORD': DBPASSWD, 'HOST': os.environ.get('DBHOST', 'localhost'), } } @@ -119,18 +119,9 @@ USE_L10N = True USE_TZ = True -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.8/howto/static-files/ - -STATIC_URL = '/static/' -STATIC_ROOT = '/var/www/static/' - # Media upload (through ImageField, SiteField) # https://docs.djangoproject.com/en/1.9/ref/models/fields/ -MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') -MEDIA_URL = '/media/' - # Various additional settings SITE_ID = 1 @@ -163,12 +154,6 @@ AUTHENTICATION_BACKENDS = ( 'kfet.backends.GenericTeamBackend', ) -# LDAP_SERVER_URL = 'ldaps://ldap.spi.ens.fr:636' - -# EMAIL_HOST="nef.ens.fr" - -RECAPTCHA_PUBLIC_KEY = "DUMMY" -RECAPTCHA_PRIVATE_KEY = "DUMMY" RECAPTCHA_USE_SSL = True # Channels settings @@ -183,22 +168,4 @@ CHANNEL_LAYERS = { } } - -def show_toolbar(request): - """ - On ne veut pas la vérification de INTERNAL_IPS faite par la debug-toolbar - car cela interfère avec l'utilisation de Vagrant. En effet, l'adresse de la - machine physique n'est pas forcément connue, et peut difficilement être - mise dans les INTERNAL_IPS. - """ - if not DEBUG: - return False - if request.is_ajax(): - return False - return True - -DEBUG_TOOLBAR_CONFIG = { - 'SHOW_TOOLBAR_CALLBACK': show_toolbar, -} - FORMAT_MODULE_PATH = 'cof.locale' diff --git a/cof/settings/dev.py b/cof/settings/dev.py new file mode 100644 index 00000000..8f5c9f29 --- /dev/null +++ b/cof/settings/dev.py @@ -0,0 +1,52 @@ +""" +Django development settings for the cof project. +The settings that are not listed here are imported from .common +""" + +import os + +from .common import * + + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +DEBUG = True + +TEMPLATES[0]["OPTIONS"]["context_processors"] += [ + 'django.template.context_processors.debug' +] + + +# --- +# Apache static/media config +# --- + +STATIC_URL = '/static/' +STATIC_ROOT = '/var/www/static/' + +MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') +MEDIA_URL = '/media/' + + +# --- +# Debug tool bar +# --- + +def show_toolbar(request): + """ + On ne veut pas la vérification de INTERNAL_IPS faite par la debug-toolbar + car cela interfère avec l'utilisation de Vagrant. En effet, l'adresse de la + machine physique n'est pas forcément connue, et peut difficilement être + mise dans les INTERNAL_IPS. + """ + if not DEBUG: + return False + if request.is_ajax(): + return False + return True + +INSTALLED_APPS += ["debug_toolbar"] +MIDDLEWARE_CLASSES += ["debug_toolbar.middleware.DebugToolbarMiddleware"] +DEBUG_TOOLBAR_CONFIG = { + 'SHOW_TOOLBAR_CALLBACK': show_toolbar, +} diff --git a/cof/settings/prod.py b/cof/settings/prod.py new file mode 100644 index 00000000..5fae5651 --- /dev/null +++ b/cof/settings/prod.py @@ -0,0 +1,26 @@ +""" +Django development settings for the cof project. +The settings that are not listed here are imported from .common +""" + +import os + +from .common import * + + +DEBUG = False + +ALLOWED_HOSTS = [ + "cof.ens.fr", + "www.cof.ens.fr", + "dev.cof.ens.fr" +] + +STATIC_ROOT = os.path.join(os.path.dirname(BASE_DIR), "static") +STATIC_URL = "/gestion/static/" +MEDIA_ROOT = os.path.join(os.path.dirname(BASE_DIR), "media") +MEDIA_URL = "/gestion/media/" + +LDAP_SERVER_URL = "ldaps://ldap.spi.ens.fr:636" + +EMAIL_HOST = "nef.ens.fr" diff --git a/cof/settings/secret_example.py b/cof/settings/secret_example.py new file mode 100644 index 00000000..36a8e932 --- /dev/null +++ b/cof/settings/secret_example.py @@ -0,0 +1,4 @@ +SECRET_KEY = 'q()(zn4m63i%5cp4)f+ww4-28_w+ly3q9=6imw2ciu&_(5_4ah' +RECAPTCHA_PUBLIC_KEY = "DUMMY" +RECAPTCHA_PRIVATE_KEY = "DUMMY" +ADMINS = None From 40abe81402649b326a4c8334290ae5177e079f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Mon, 10 Apr 2017 22:44:52 +0100 Subject: [PATCH 43/90] Integrate the new settings workflow into vagrant --- provisioning/bootstrap.sh | 7 +++++-- provisioning/cron.dev | 2 +- provisioning/supervisor.conf | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index 269e4f25..c8f73ab6 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -36,7 +36,7 @@ chown -R ubuntu:www-data /var/www/static # Mise en place du .bash_profile pour tout configurer lors du `vagrant ssh` cat >> ~ubuntu/.bashrc < Date: Tue, 11 Apr 2017 23:13:54 +0200 Subject: [PATCH 44/90] Fewer queries on stats of an account balance. - Remove labels, should be replaced to an anchor to the relative operation in history. - Add select_related as necessary. --- kfet/views.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/kfet/views.py b/kfet/views.py index 2c4a4f73..cfc58aa0 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -2219,10 +2219,13 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView): # prepare querysets # TODO: retirer les opgroup dont tous les op sont annulées opegroups = OperationGroup.objects.filter(on_acc=account) - recv_transfers = Transfer.objects.filter(to_acc=account, - canceled_at=None) - sent_transfers = Transfer.objects.filter(from_acc=account, - canceled_at=None) + transfers = ( + Transfer.objects + .filter(canceled_at=None) + .select_related('group') + ) + recv_transfers = transfers.filter(to_acc=account) + sent_transfers = transfers.filter(from_acc=account) # apply filters if begin_date is not None: @@ -2250,13 +2253,11 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView): actions.append({ 'at': (begin_date or account.created_at).isoformat(), 'amount': 0, - 'label': 'début', 'balance': 0, }) actions.append({ 'at': (end_date or timezone.now()).isoformat(), 'amount': 0, - 'label': 'fin', 'balance': 0, }) @@ -2264,21 +2265,18 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView): { 'at': ope_grp.at.isoformat(), 'amount': ope_grp.amount, - 'label': str(ope_grp), 'balance': 0, } for ope_grp in opegroups ] + [ { 'at': tr.group.at.isoformat(), 'amount': tr.amount, - 'label': str(tr), 'balance': 0, } for tr in recv_transfers ] + [ { 'at': tr.group.at.isoformat(), 'amount': -tr.amount, - 'label': str(tr), 'balance': 0, } for tr in sent_transfers ] From 3f4a1adbb9cd083731b3ad340c47dae51c461586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Wed, 12 Apr 2017 18:03:31 +0200 Subject: [PATCH 45/90] Fewer queries on stats/scales + Fix Scales: - Fix #chunks when used with std_chunk=True (there was one too many at the beginning) - Scale.end gives the end of the last chunk (instead of its start) So scale.begin -> scale.end gives the full range of the scale. `kfet_day` now returns an aware datetime. ScaleMixin: - new method `get_by_chunks` which use only one query and ranks elements according to the scale. Elements are returned by a generator for each scale chunk (and all chunks are returned as a generator too). ArticlesStatSales and AccountStatOperations use this new method to avoid issuing #scale_chunks queries. ArticleStat: - fixed on Chrome --- kfet/static/kfet/js/statistic.js | 4 +- kfet/statistic.py | 103 ++++++++++++++++++++++++-- kfet/templates/kfet/article_read.html | 2 +- kfet/views.py | 63 ++++++++++------ 4 files changed, 140 insertions(+), 32 deletions(-) diff --git a/kfet/static/kfet/js/statistic.js b/kfet/static/kfet/js/statistic.js index f210c11d..db31e0e8 100644 --- a/kfet/static/kfet/js/statistic.js +++ b/kfet/static/kfet/js/statistic.js @@ -61,7 +61,7 @@ var chart = charts[i]; // format the data - var chart_data = is_time_chart ? handleTimeChart(chart.values) : dictToArray(chart.values, 1); + var chart_data = is_time_chart ? handleTimeChart(chart.values) : dictToArray(chart.values, 0); chart_datasets.push( { @@ -132,7 +132,7 @@ type: 'line', options: chart_options, data: { - labels: (data.labels || []).slice(1), + labels: data.labels || [], datasets: chart_datasets, } }; diff --git a/kfet/statistic.py b/kfet/statistic.py index fe948f73..5ff169ff 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -4,6 +4,7 @@ from datetime import date, datetime, time, timedelta from dateutil.relativedelta import relativedelta from dateutil.parser import parse as dateutil_parse +import pytz from django.utils import timezone from django.db.models import Sum @@ -13,7 +14,8 @@ KFET_WAKES_UP_AT = time(7, 0) def kfet_day(year, month, day, start_at=KFET_WAKES_UP_AT): """datetime wrapper with time offset.""" - return datetime.combine(date(year, month, day), start_at) + naive = datetime.combine(date(year, month, day), start_at) + return pytz.timezone('Europe/Paris').localize(naive, is_dst=None) def to_kfet_day(dt, start_at=KFET_WAKES_UP_AT): @@ -32,16 +34,21 @@ class Scale(object): self.std_chunk = std_chunk if last: end = timezone.now() + if std_chunk: + if begin is not None: + begin = self.get_chunk_start(begin) + if end is not None: + end = self.do_step(self.get_chunk_start(end)) if begin is not None and n_steps != 0: - self.begin = self.get_from(begin) + self.begin = begin self.end = self.do_step(self.begin, n_steps=n_steps) elif end is not None and n_steps != 0: - self.end = self.get_from(end) + self.end = end self.begin = self.do_step(self.end, n_steps=-n_steps) elif begin is not None and end is not None: - self.begin = self.get_from(begin) - self.end = self.get_from(end) + self.begin = begin + self.end = end else: raise Exception('Two of these args must be specified: ' 'n_steps, begin, end; ' @@ -71,7 +78,7 @@ class Scale(object): def get_datetimes(self): datetimes = [self.begin] tmp = self.begin - while tmp <= self.end: + while tmp < self.end: tmp = self.do_step(tmp) datetimes.append(tmp) return datetimes @@ -232,3 +239,87 @@ class ScaleMixin(object): qs.filter(**{begin_f: begin, end_f: end}) for begin, end in scale ] + + def get_by_chunks(self, qs, scale, field_callback=None, field_db='at'): + """Objects of queryset ranked according to a given scale. + + Returns a generator whose each item, corresponding to a scale chunk, + is a generator of objects from qs for this chunk. + + Args: + qs: Queryset of source objects, must be ordered *first* on the + same field returned by `field_callback`. + scale: Used to rank objects. + field_callback: Callable which gives value from an object used + to compare against limits of the scale chunks. + Default to: lambda obj: getattr(obj, field_db) + field_db: Used to filter against `scale` limits. + Default to 'at'. + + Examples: + If queryset `qs` use `values()`, `field_callback` must be set and + could be: `lambda d: d['at']` + If `field_db` use foreign attributes (eg with `__`), it should be + something like: `lambda obj: obj.group.at`. + + """ + if field_callback is None: + def field_callback(obj): + return getattr(obj, field_db) + + begin_f = '{}__gte'.format(field_db) + end_f = '{}__lte'.format(field_db) + + qs = ( + qs + .filter(**{begin_f: scale.begin, end_f: scale.end}) + ) + + obj_iter = iter(qs) + + last_obj = None + + def _objects_until(obj_iter, field_callback, end): + """Generator of objects until `end`. + + Ends if objects source is empty or when an object not verifying + field_callback(obj) <= end is met. + + If this object exists, it is stored in `last_obj` which is found + from outer scope. + Also, if this same variable is non-empty when the function is + called, it first yields its content. + + Args: + obj_iter: Source used to get objects. + field_callback: Returned value, when it is called on an object + will be used to test ordering against `end`. + end + + """ + nonlocal last_obj + + if last_obj is not None: + yield last_obj + last_obj = None + + for obj in obj_iter: + if field_callback(obj) <= end: + yield obj + else: + last_obj = obj + return + + for begin, end in scale: + # forward last seen object, if it exists, to the right chunk, + # and fill with empty generators for intermediate chunks of scale + if last_obj is not None: + if field_callback(last_obj) > end: + yield iter(()) + continue + + # yields generator for this chunk + # this set last_obj to None if obj_iter reach its end, otherwise + # it's set to the first met object from obj_iter which doesn't + # belong to this chunk + yield _objects_until(obj_iter, field_callback, end) diff --git a/kfet/templates/kfet/article_read.html b/kfet/templates/kfet/article_read.html index 6fe025f6..19a11094 100644 --- a/kfet/templates/kfet/article_read.html +++ b/kfet/templates/kfet/article_read.html @@ -104,7 +104,7 @@ $(document).ready(function() { var stat_last = new StatsGroup( "{% url 'kfet.article.stat.sales.list' article.id %}", - $("#stat_last"), + $("#stat_last") ); }); diff --git a/kfet/views.py b/kfet/views.py index cfc58aa0..1df78d1e 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -2369,13 +2369,19 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): # à l'article en question et qui ne sont pas annulées # puis on choisi pour chaques intervalle les opérations # effectuées dans ces intervalles de temps - all_operations = (Operation.objects - .filter(group__on_acc=self.object) - .filter(canceled_at=None) - ) + all_operations = ( + Operation.objects + .filter(group__on_acc=self.object, + canceled_at=None) + .values('article_nb', 'group__at') + .order_by('group__at') + ) if types is not None: all_operations = all_operations.filter(type__in=types) - chunks = self.chunkify_qs(all_operations, scale, field='group__at') + chunks = self.get_by_chunks( + all_operations, scale, field_db='group__at', + field_callback=(lambda d: d['group__at']), + ) return chunks def get_context_data(self, *args, **kwargs): @@ -2391,7 +2397,8 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): # On compte les opérations nb_ventes = [] for chunk in operations: - nb_ventes.append(tot_ventes(chunk)) + ventes = sum(ope['article_nb'] for ope in chunk) + nb_ventes.append(ventes) context['charts'] = [{"color": "rgb(255, 99, 132)", "label": "NB items achetés", @@ -2442,29 +2449,39 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): context = {'labels': old_ctx['labels']} scale = self.scale - # On selectionne les opérations qui correspondent - # à l'article en question et qui ne sont pas annulées - # puis on choisi pour chaques intervalle les opérations - # effectuées dans ces intervalles de temps - all_operations = ( + all_purchases = ( Operation.objects - .filter(type=Operation.PURCHASE, - article=self.object, - canceled_at=None, - ) + .filter( + type=Operation.PURCHASE, + article=self.object, + canceled_at=None, + ) + .values('group__at', 'article_nb') + .order_by('group__at') ) - chunks = self.chunkify_qs(all_operations, scale, field='group__at') + liq_only = all_purchases.filter(group__on_acc__trigramme='LIQ') + liq_exclude = all_purchases.exclude(group__on_acc__trigramme='LIQ') + + chunks_liq = self.get_by_chunks( + liq_only, scale, field_db='group__at', + field_callback=lambda d: d['group__at'], + ) + chunks_no_liq = self.get_by_chunks( + liq_exclude, scale, field_db='group__at', + field_callback=lambda d: d['group__at'], + ) + # On compte les opérations nb_ventes = [] nb_accounts = [] nb_liq = [] - for qs in chunks: - nb_ventes.append( - tot_ventes(qs)) - nb_liq.append( - tot_ventes(qs.filter(group__on_acc__trigramme='LIQ'))) - nb_accounts.append( - tot_ventes(qs.exclude(group__on_acc__trigramme='LIQ'))) + for chunk_liq, chunk_no_liq in zip(chunks_liq, chunks_no_liq): + sum_accounts = sum(ope['article_nb'] for ope in chunk_no_liq) + sum_liq = sum(ope['article_nb'] for ope in chunk_liq) + nb_ventes.append(sum_accounts + sum_liq) + nb_accounts.append(sum_accounts) + nb_liq.append(sum_liq) + context['charts'] = [{"color": "rgb(255, 99, 132)", "label": "Toutes consommations", "values": nb_ventes}, From 06572f0bb5b7994910e7bed6be29dcc90aa7d25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 13 Apr 2017 14:11:44 +0200 Subject: [PATCH 46/90] Order create use Scale. Order create view use WeekScale. No query improvements, only shorter code. Scale/ScaleMixin: - Two methods directly relative to the Scale class move to... the Scale class. - Fix order create on Chrome. --- kfet/statistic.py | 187 +++++++++++----------- kfet/templates/kfet/inventory_create.html | 2 + kfet/views.py | 113 +++++++------ 3 files changed, 149 insertions(+), 153 deletions(-) diff --git a/kfet/statistic.py b/kfet/statistic.py index 5ff169ff..8ffb7db5 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -88,6 +88,99 @@ class Scale(object): label_fmt = self.label_fmt return [begin.strftime(label_fmt) for begin, end in self] + def chunkify_qs(self, qs, field=None): + if field is None: + field = 'at' + begin_f = '{}__gte'.format(field) + end_f = '{}__lte'.format(field) + return [ + qs.filter(**{begin_f: begin, end_f: end}) + for begin, end in self + ] + + def get_by_chunks(self, qs, field_callback=None, field_db='at'): + """Objects of queryset ranked according to the scale. + + Returns a generator whose each item, corresponding to a scale chunk, + is a generator of objects from qs for this chunk. + + Args: + qs: Queryset of source objects, must be ordered *first* on the + same field returned by `field_callback`. + field_callback: Callable which gives value from an object used + to compare against limits of the scale chunks. + Default to: lambda obj: getattr(obj, field_db) + field_db: Used to filter against `scale` limits. + Default to 'at'. + + Examples: + If queryset `qs` use `values()`, `field_callback` must be set and + could be: `lambda d: d['at']` + If `field_db` use foreign attributes (eg with `__`), it should be + something like: `lambda obj: obj.group.at`. + + """ + if field_callback is None: + def field_callback(obj): + return getattr(obj, field_db) + + begin_f = '{}__gte'.format(field_db) + end_f = '{}__lte'.format(field_db) + + qs = ( + qs + .filter(**{begin_f: self.begin, end_f: self.end}) + ) + + obj_iter = iter(qs) + + last_obj = None + + def _objects_until(obj_iter, field_callback, end): + """Generator of objects until `end`. + + Ends if objects source is empty or when an object not verifying + field_callback(obj) <= end is met. + + If this object exists, it is stored in `last_obj` which is found + from outer scope. + Also, if this same variable is non-empty when the function is + called, it first yields its content. + + Args: + obj_iter: Source used to get objects. + field_callback: Returned value, when it is called on an object + will be used to test ordering against `end`. + end + + """ + nonlocal last_obj + + if last_obj is not None: + yield last_obj + last_obj = None + + for obj in obj_iter: + if field_callback(obj) <= end: + yield obj + else: + last_obj = obj + return + + for begin, end in self: + # forward last seen object, if it exists, to the right chunk, + # and fill with empty generators for intermediate chunks of scale + if last_obj is not None: + if field_callback(last_obj) > end: + yield iter(()) + continue + + # yields generator for this chunk + # this set last_obj to None if obj_iter reach its end, otherwise + # it's set to the first met object from obj_iter which doesn't + # belong to this chunk + yield _objects_until(obj_iter, field_callback, end) + class DayScale(Scale): name = 'day' @@ -229,97 +322,3 @@ class ScaleMixin(object): def get_default_scale(self): return DayScale(n_steps=7, last=True) - - def chunkify_qs(self, qs, scale, field=None): - if field is None: - field = 'at' - begin_f = '{}__gte'.format(field) - end_f = '{}__lte'.format(field) - return [ - qs.filter(**{begin_f: begin, end_f: end}) - for begin, end in scale - ] - - def get_by_chunks(self, qs, scale, field_callback=None, field_db='at'): - """Objects of queryset ranked according to a given scale. - - Returns a generator whose each item, corresponding to a scale chunk, - is a generator of objects from qs for this chunk. - - Args: - qs: Queryset of source objects, must be ordered *first* on the - same field returned by `field_callback`. - scale: Used to rank objects. - field_callback: Callable which gives value from an object used - to compare against limits of the scale chunks. - Default to: lambda obj: getattr(obj, field_db) - field_db: Used to filter against `scale` limits. - Default to 'at'. - - Examples: - If queryset `qs` use `values()`, `field_callback` must be set and - could be: `lambda d: d['at']` - If `field_db` use foreign attributes (eg with `__`), it should be - something like: `lambda obj: obj.group.at`. - - """ - if field_callback is None: - def field_callback(obj): - return getattr(obj, field_db) - - begin_f = '{}__gte'.format(field_db) - end_f = '{}__lte'.format(field_db) - - qs = ( - qs - .filter(**{begin_f: scale.begin, end_f: scale.end}) - ) - - obj_iter = iter(qs) - - last_obj = None - - def _objects_until(obj_iter, field_callback, end): - """Generator of objects until `end`. - - Ends if objects source is empty or when an object not verifying - field_callback(obj) <= end is met. - - If this object exists, it is stored in `last_obj` which is found - from outer scope. - Also, if this same variable is non-empty when the function is - called, it first yields its content. - - Args: - obj_iter: Source used to get objects. - field_callback: Returned value, when it is called on an object - will be used to test ordering against `end`. - end - - """ - nonlocal last_obj - - if last_obj is not None: - yield last_obj - last_obj = None - - for obj in obj_iter: - if field_callback(obj) <= end: - yield obj - else: - last_obj = obj - return - - for begin, end in scale: - # forward last seen object, if it exists, to the right chunk, - # and fill with empty generators for intermediate chunks of scale - if last_obj is not None: - if field_callback(last_obj) > end: - yield iter(()) - continue - - # yields generator for this chunk - # this set last_obj to None if obj_iter reach its end, otherwise - # it's set to the first met object from obj_iter which doesn't - # belong to this chunk - yield _objects_until(obj_iter, field_callback, end) diff --git a/kfet/templates/kfet/inventory_create.html b/kfet/templates/kfet/inventory_create.html index d8109f8e..0192d4ad 100644 --- a/kfet/templates/kfet/inventory_create.html +++ b/kfet/templates/kfet/inventory_create.html @@ -161,6 +161,8 @@ $(document).ready(function() { $('input[type="submit"]').on("click", function(e) { e.preventDefault(); + var content; + if (conflicts.size) { content = ''; content += "Conflits possibles :" diff --git a/kfet/views.py b/kfet/views.py index 1df78d1e..45d9d1cb 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -50,7 +50,7 @@ from decimal import Decimal import django_cas_ng import heapq import statistics -from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes +from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes, WeekScale class Home(TemplateView): @@ -1798,68 +1798,60 @@ class OrderList(ListView): context['suppliers'] = Supplier.objects.order_by('name') return context + @teamkfet_required def order_create(request, pk): supplier = get_object_or_404(Supplier, pk=pk) - articles = (Article.objects - .filter(suppliers=supplier.pk) - .distinct() - .select_related('category') - .order_by('category__name', 'name')) + articles = ( + Article.objects + .filter(suppliers=supplier.pk) + .distinct() + .select_related('category') + .order_by('category__name', 'name') + ) - initial = [] - today = timezone.now().date() - sales_q = (Operation.objects + # Force hit to cache + articles = list(articles) + + sales_q = ( + Operation.objects .select_related('group') .filter(article__in=articles, canceled_at=None) - .values('article')) - sales_s1 = (sales_q - .filter( - group__at__gte = today-timedelta(weeks=5), - group__at__lt = today-timedelta(weeks=4)) + .values('article') .annotate(nb=Sum('article_nb')) ) - sales_s1 = { d['article']:d['nb'] for d in sales_s1 } - sales_s2 = (sales_q - .filter( - group__at__gte = today-timedelta(weeks=4), - group__at__lt = today-timedelta(weeks=3)) - .annotate(nb=Sum('article_nb')) - ) - sales_s2 = { d['article']:d['nb'] for d in sales_s2 } - sales_s3 = (sales_q - .filter( - group__at__gte = today-timedelta(weeks=3), - group__at__lt = today-timedelta(weeks=2)) - .annotate(nb=Sum('article_nb')) - ) - sales_s3 = { d['article']:d['nb'] for d in sales_s3 } - sales_s4 = (sales_q - .filter( - group__at__gte = today-timedelta(weeks=2), - group__at__lt = today-timedelta(weeks=1)) - .annotate(nb=Sum('article_nb')) - ) - sales_s4 = { d['article']:d['nb'] for d in sales_s4 } - sales_s5 = (sales_q - .filter(group__at__gte = today-timedelta(weeks=1)) - .annotate(nb=Sum('article_nb')) - ) - sales_s5 = { d['article']:d['nb'] for d in sales_s5 } + scale = WeekScale(last=True, n_steps=5, std_chunk=False) + chunks = scale.chunkify_qs(sales_q, field='group__at') + sales = [] + + for chunk in chunks: + sales.append( + {d['article']: d['nb'] for d in chunk} + ) + + initial = [] for article in articles: - v_s1 = sales_s1.get(article.pk, 0) - v_s2 = sales_s2.get(article.pk, 0) - v_s3 = sales_s3.get(article.pk, 0) - v_s4 = sales_s4.get(article.pk, 0) - v_s5 = sales_s5.get(article.pk, 0) + # Get sales for each 5 last weeks + v_s1 = sales[0].get(article.pk, 0) + v_s2 = sales[1].get(article.pk, 0) + v_s3 = sales[2].get(article.pk, 0) + v_s4 = sales[3].get(article.pk, 0) + v_s5 = sales[4].get(article.pk, 0) v_all = [v_s1, v_s2, v_s3, v_s4, v_s5] + # Take the 3 greatest (eg to avoid 2 weeks of vacations) v_3max = heapq.nlargest(3, v_all) + # Get average and standard deviation v_moy = statistics.mean(v_3max) v_et = statistics.pstdev(v_3max, v_moy) + # Expected sales for next week v_prev = v_moy + v_et + # We want to have 1.5 * the expected sales in stock + # (because sometimes some articles are not delivered) c_rec_tot = max(v_prev * 1.5 - article.stock, 0) + # If ordered quantity is close enough to a level which can led to free + # boxes, we increase it to this level. if article.box_capacity: c_rec_temp = c_rec_tot / article.box_capacity if c_rec_temp >= 10: @@ -1889,8 +1881,9 @@ def order_create(request, pk): }) cls_formset = formset_factory( - form = OrderArticleForm, - extra = 0) + form=OrderArticleForm, + extra=0, + ) if request.POST: formset = cls_formset(request.POST, initial=initial) @@ -1907,14 +1900,15 @@ def order_create(request, pk): order.save() saved = True - article = articles.get(pk=form.cleaned_data['article'].pk) + article = form.cleaned_data['article'] q_ordered = form.cleaned_data['quantity_ordered'] if article.box_capacity: q_ordered *= article.box_capacity OrderArticle.objects.create( - order = order, - article = article, - quantity_ordered = q_ordered) + order=order, + article=article, + quantity_ordered=q_ordered, + ) if saved: messages.success(request, 'Commande créée') return redirect('kfet.order.read', order.pk) @@ -1926,9 +1920,10 @@ def order_create(request, pk): return render(request, 'kfet/order_create.html', { 'supplier': supplier, - 'formset' : formset, + 'formset': formset, }) + class OrderRead(DetailView): model = Order template_name = 'kfet/order_read.html' @@ -2378,8 +2373,8 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): ) if types is not None: all_operations = all_operations.filter(type__in=types) - chunks = self.get_by_chunks( - all_operations, scale, field_db='group__at', + chunks = scale.get_by_chunks( + all_operations, field_db='group__at', field_callback=(lambda d: d['group__at']), ) return chunks @@ -2462,12 +2457,12 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): liq_only = all_purchases.filter(group__on_acc__trigramme='LIQ') liq_exclude = all_purchases.exclude(group__on_acc__trigramme='LIQ') - chunks_liq = self.get_by_chunks( - liq_only, scale, field_db='group__at', + chunks_liq = scale.get_by_chunks( + liq_only, field_db='group__at', field_callback=lambda d: d['group__at'], ) - chunks_no_liq = self.get_by_chunks( - liq_exclude, scale, field_db='group__at', + chunks_no_liq = scale.get_by_chunks( + liq_exclude, field_db='group__at', field_callback=lambda d: d['group__at'], ) From 18425b82c21c8c5d1fd0be0e2e7d776e5dd86b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 13 Apr 2017 15:15:59 +0200 Subject: [PATCH 47/90] Check negative on cancellation. - Like perform operations, cancel_operations can add/remove an account from negative accounts system. - Balances checks are now performed against real_balance instead of balance. So if someone with a balance_offset go, for real, to positive land (ie even without taking into account the balance offset), its account is removed from the negative system. - Fix bug on real_balance when negative exists but balance_offset is not set. Fixes #156. --- kfet/models.py | 25 ++++++++++++++++++++++++- kfet/views.py | 36 ++++++++++++++++++------------------ 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/kfet/models.py b/kfet/models.py index 7c03191a..af24db49 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -81,7 +81,7 @@ class Account(models.Model): # Propriétés supplémentaires @property def real_balance(self): - if (hasattr(self, 'negative')): + if hasattr(self, 'negative') and self.negative.balance_offset: return self.balance - self.negative.balance_offset return self.balance @@ -210,6 +210,29 @@ class Account(models.Model): def delete(self, *args, **kwargs): pass + def check_negative(self): + if self.real_balance < 0: + if hasattr(self, 'negative') and not self.negative.start: + self.negative.start = timezone.now() + self.negative.save() + elif not hasattr(self, 'negative'): + self.negative = ( + AccountNegative.objects.create( + account=self, start=timezone.now(), + ) + ) + elif hasattr(self, 'negative'): + # self.real_balance >= 0 + balance_offset = self.negative.balance_offset + if balance_offset: + ( + Account.objects + .filter(pk=self.pk) + .update(balance=F('balance')-balance_offset) + ) + self.refresh_from_db() + self.negative.delete() + class UserHasAccount(Exception): def __init__(self, trigramme): self.trigramme = trigramme diff --git a/kfet/views.py b/kfet/views.py index 330b195a..82ef8433 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1105,22 +1105,15 @@ def kpsul_perform_operations(request): with transaction.atomic(): # If not cash account, # saving account's balance and adding to Negative if not in - if not operationgroup.on_acc.is_cash: - Account.objects.filter(pk=operationgroup.on_acc.pk).update( - balance=F('balance') + operationgroup.amount) - operationgroup.on_acc.refresh_from_db() - if operationgroup.on_acc.balance < 0: - if hasattr(operationgroup.on_acc, 'negative'): - if not operationgroup.on_acc.negative.start: - operationgroup.on_acc.negative.start = timezone.now() - operationgroup.on_acc.negative.save() - else: - negative = AccountNegative( - account=operationgroup.on_acc, start=timezone.now()) - negative.save() - elif (hasattr(operationgroup.on_acc, 'negative') and - not operationgroup.on_acc.negative.balance_offset): - operationgroup.on_acc.negative.delete() + on_acc = operationgroup.on_acc + if not on_acc.is_cash: + ( + Account.objects + .filter(pk=on_acc.pk) + .update(balance=F('balance') + operationgroup.amount) + ) + on_acc.refresh_from_db() + on_acc.check_negative() # Updating checkout's balance if to_checkout_balance: @@ -1311,8 +1304,15 @@ def kpsul_cancel_operations(request): (Operation.objects.filter(pk__in=opes) .update(canceled_by=canceled_by, canceled_at=canceled_at)) for account in to_accounts_balances: - Account.objects.filter(pk=account.pk).update( - balance = F('balance') + to_accounts_balances[account]) + ( + Account.objects + .filter(pk=account.pk) + .update(balance=F('balance') + to_accounts_balances[account]) + ) + if not account.is_cash: + # Should always be true, but we want to be sure + account.refresh_from_db() + account.check_negative() for checkout in to_checkouts_balances: Checkout.objects.filter(pk=checkout.pk).update( balance = F('balance') + to_checkouts_balances[checkout]) From 9668f1d1ec94dc92d6befa6b61bcffc540eb1c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 13 Apr 2017 15:48:13 +0200 Subject: [PATCH 48/90] Account: check_negative() -> update_negative() --- kfet/models.py | 2 +- kfet/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kfet/models.py b/kfet/models.py index af24db49..6c1f1240 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -210,7 +210,7 @@ class Account(models.Model): def delete(self, *args, **kwargs): pass - def check_negative(self): + def update_negative(self): if self.real_balance < 0: if hasattr(self, 'negative') and not self.negative.start: self.negative.start = timezone.now() diff --git a/kfet/views.py b/kfet/views.py index 82ef8433..60dbb44b 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1113,7 +1113,7 @@ def kpsul_perform_operations(request): .update(balance=F('balance') + operationgroup.amount) ) on_acc.refresh_from_db() - on_acc.check_negative() + on_acc.update_negative() # Updating checkout's balance if to_checkout_balance: @@ -1312,7 +1312,7 @@ def kpsul_cancel_operations(request): if not account.is_cash: # Should always be true, but we want to be sure account.refresh_from_db() - account.check_negative() + account.update_negative() for checkout in to_checkouts_balances: Checkout.objects.filter(pk=checkout.pk).update( balance = F('balance') + to_checkouts_balances[checkout]) From 7db497d09593bf3e5990e399bb110fd012421cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 13 Apr 2017 16:34:29 +0200 Subject: [PATCH 49/90] Less articles prices history - Prices given with order_to_inventory are saved to db only if they are updated (it doesn't create a new price row each time) Fixes #142. --- kfet/views.py | 94 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/kfet/views.py b/kfet/views.py index 330b195a..a33e3fc0 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1946,6 +1946,7 @@ class OrderRead(DetailView): context['mail'] = mail return context + @teamkfet_required def order_to_inventory(request, pk): order = get_object_or_404(Order, pk=pk) @@ -1953,28 +1954,36 @@ def order_to_inventory(request, pk): if hasattr(order, 'inventory'): raise Http404 - articles = (Article.objects - .filter(orders=order.pk) - .select_related('category') - .prefetch_related(Prefetch('orderarticle_set', - queryset = OrderArticle.objects.filter(order=order), - to_attr = 'order')) - .prefetch_related(Prefetch('supplierarticle_set', - queryset = (SupplierArticle.objects - .filter(supplier=order.supplier) - .order_by('-at')), - to_attr = 'supplier')) - .order_by('category__name', 'name')) + supplier_prefetch = Prefetch( + 'article__supplierarticle_set', + queryset=( + SupplierArticle.objects + .filter(supplier=order.supplier) + .order_by('-at') + ), + to_attr='supplier', + ) + + order_articles = ( + OrderArticle.objects + .filter(order=order.pk) + .select_related('article', 'article__category') + .prefetch_related( + supplier_prefetch, + ) + .order_by('article__category__name', 'article__name') + ) initial = [] - for article in articles: + for order_article in order_articles: + article = order_article.article initial.append({ 'article': article.pk, 'name': article.name, 'category': article.category_id, 'category__name': article.category.name, - 'quantity_ordered': article.order[0].quantity_ordered, - 'quantity_received': article.order[0].quantity_ordered, + 'quantity_ordered': order_article.quantity_ordered, + 'quantity_received': order_article.quantity_ordered, 'price_HT': article.supplier[0].price_HT, 'TVA': article.supplier[0].TVA, 'rights': article.supplier[0].rights, @@ -1989,31 +1998,50 @@ def order_to_inventory(request, pk): messages.error(request, 'Permission refusée') elif formset.is_valid(): with transaction.atomic(): - inventory = Inventory() - inventory.order = order - inventory.by = request.user.profile.account_kfet - inventory.save() + inventory = Inventory.objects.create( + order=order, by=request.user.profile.account_kfet, + ) + new_supplierarticle = [] + new_inventoryarticle = [] for form in formset: q_received = form.cleaned_data['quantity_received'] article = form.cleaned_data['article'] - SupplierArticle.objects.create( - supplier = order.supplier, - article = article, - price_HT = form.cleaned_data['price_HT'], - TVA = form.cleaned_data['TVA'], - rights = form.cleaned_data['rights']) - (OrderArticle.objects - .filter(order=order, article=article) - .update(quantity_received = q_received)) - InventoryArticle.objects.create( - inventory = inventory, - article = article, - stock_old = article.stock, - stock_new = article.stock + q_received) + + price_HT = form.cleaned_data['price_HT'] + TVA = form.cleaned_data['TVA'] + rights = form.cleaned_data['rights'] + + if any((form.initial['price_HT'] != price_HT, + form.initial['TVA'] != TVA, + form.initial['rights'] != rights)): + new_supplierarticle.append( + SupplierArticle( + supplier=order.supplier, + article=article, + price_HT=price_HT, + TVA=TVA, + rights=rights, + ) + ) + ( + OrderArticle.objects + .filter(order=order, article=article) + .update(quantity_received=q_received) + ) + new_inventoryarticle.append( + InventoryArticle( + inventory=inventory, + article=article, + stock_old=article.stock, + stock_new=article.stock + q_received, + ) + ) article.stock += q_received if q_received > 0: article.is_sold = True article.save() + SupplierArticle.objects.bulk_create(new_supplierarticle) + InventoryArticle.objects.bulk_create(new_inventoryarticle) messages.success(request, "C'est tout bon !") return redirect('kfet.order') else: From 0a21858b337c543b46d49a4cae9851dc17f86da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 14 Apr 2017 13:08:03 +0200 Subject: [PATCH 50/90] Use css for scroll positionning - Better rendering on scroll on pages with a left block (- It removes the warning on Firefox about scroll positionning) --- kfet/static/kfet/css/index.css | 7 +++++++ kfet/static/kfet/js/kfet.js | 10 ---------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index 0244a57b..ce7e0dff 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -134,6 +134,13 @@ textarea { padding:0; } +@media (min-width: 768px) { + .col-content-left { + position: sticky; + top:50px; + } +} + .content-left-top { background:#fff; padding:10px 30px; diff --git a/kfet/static/kfet/js/kfet.js b/kfet/static/kfet/js/kfet.js index cc369e32..72ae675a 100644 --- a/kfet/static/kfet/js/kfet.js +++ b/kfet/static/kfet/js/kfet.js @@ -1,14 +1,4 @@ $(document).ready(function() { - $(window).scroll(function() { - if ($(window).width() >= 768 && $(this).scrollTop() > 72.6) { - $('.col-content-left').css({'position':'fixed', 'top':'50px'}); - $('.col-content-right').addClass('col-sm-offset-4 col-md-offset-3'); - } else { - $('.col-content-left').css({'position':'relative', 'top':'0'}); - $('.col-content-right').removeClass('col-sm-offset-4 col-md-offset-3'); - } - }); - if (typeof Cookies !== 'undefined') { // Retrieving csrf token csrftoken = Cookies.get('csrftoken'); From ff73a635f82dfb85af4922f6d5319e06c8f29e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sat, 15 Apr 2017 11:09:16 +0100 Subject: [PATCH 51/90] Minor fixes in settings/ - Typo - Removes old comments - Moves the template debug context processor back to the common file: it won't be loaded anyway if `DEBUG=False`. - Ddt's middleware should be loaded first --- cof/settings/common.py | 7 ++----- cof/settings/dev.py | 9 ++++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/cof/settings/common.py b/cof/settings/common.py index 4a6f9a12..93b11dae 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -3,7 +3,7 @@ Django common settings for cof project. Everything which is supposed to be identical between the production server and -the local development serveur should be here. +the local development server should be here. """ import os @@ -79,6 +79,7 @@ TEMPLATES = [ 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ + 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', @@ -118,10 +119,6 @@ USE_L10N = True USE_TZ = True - -# Media upload (through ImageField, SiteField) -# https://docs.djangoproject.com/en/1.9/ref/models/fields/ - # Various additional settings SITE_ID = 1 diff --git a/cof/settings/dev.py b/cof/settings/dev.py index 8f5c9f29..6272e6d9 100644 --- a/cof/settings/dev.py +++ b/cof/settings/dev.py @@ -12,10 +12,6 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEBUG = True -TEMPLATES[0]["OPTIONS"]["context_processors"] += [ - 'django.template.context_processors.debug' -] - # --- # Apache static/media config @@ -46,7 +42,10 @@ def show_toolbar(request): return True INSTALLED_APPS += ["debug_toolbar"] -MIDDLEWARE_CLASSES += ["debug_toolbar.middleware.DebugToolbarMiddleware"] +MIDDLEWARE_CLASSES = ( + ["debug_toolbar.middleware.DebugToolbarMiddleware"] + + MIDDLEWARE_CLASSES +) DEBUG_TOOLBAR_CONFIG = { 'SHOW_TOOLBAR_CALLBACK': show_toolbar, } From ea81ab7b25b9819505a186c19b3505449b2e8aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 15 Apr 2017 12:36:11 +0200 Subject: [PATCH 52/90] Few display improvements. - Current day remains on the screen on history. - Message for generic team user connection is sended only if user is connecting from a k-fet url. - Less contrast on history. --- kfet/signals.py | 16 +++++++++------- kfet/static/kfet/css/history.css | 5 ++++- kfet/static/kfet/css/index.css | 7 +++++-- kfet/static/kfet/css/kpsul.css | 4 ++++ kfet/templates/kfet/base_messages.html | 2 +- kfet/templates/kfet/history.html | 2 -- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/kfet/signals.py b/kfet/signals.py index 3dd4d677..77c114e3 100644 --- a/kfet/signals.py +++ b/kfet/signals.py @@ -1,16 +1,18 @@ # -*- coding: utf-8 -*- -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * - from django.contrib import messages from django.contrib.auth.signals import user_logged_in from django.core.urlresolvers import reverse from django.dispatch import receiver + @receiver(user_logged_in) def messages_on_login(sender, request, user, **kwargs): - if (not user.username == 'kfet_genericteam' - and user.has_perm('kfet.is_team')): - messages.info(request, 'Connexion en utilisateur partagé ?' % reverse('kfet.login.genericteam'), extra_tags='safe') + if (not user.username == 'kfet_genericteam' and + user.has_perm('kfet.is_team') and + 'k-fet' in request.GET.get('next', '')): + messages.info( + request, + ('Connexion en utilisateur partagé ?' + .format(reverse('kfet.login.genericteam'))), + extra_tags='safe') diff --git a/kfet/static/kfet/css/history.css b/kfet/static/kfet/css/history.css index 976f5782..77ccaebb 100644 --- a/kfet/static/kfet/css/history.css +++ b/kfet/static/kfet/css/history.css @@ -14,12 +14,15 @@ padding-left:20px; font-size:16px; font-weight:bold; + position:sticky; + top:50px; + z-index:10; } #history .opegroup { height:30px; line-height:30px; - background-color:rgba(200,16,46,0.85); + background-color:rgba(200,16,46,0.75); color:#fff; font-weight:bold; padding-left:20px; diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index ce7e0dff..d241e209 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -186,8 +186,11 @@ textarea { text-align:center; } -.content-right { - margin:0 15px; + +@media (min-width: 768px) { + .content-right { + margin: 0 15px; + } } .content-right-block { diff --git a/kfet/static/kfet/css/kpsul.css b/kfet/static/kfet/css/kpsul.css index ba88e433..3a08a3bb 100644 --- a/kfet/static/kfet/css/kpsul.css +++ b/kfet/static/kfet/css/kpsul.css @@ -423,3 +423,7 @@ input[type=number]::-webkit-outer-spin-button { .kpsul_middle_right_col { overflow:auto; } + +.kpsul_middle_right_col #history .day { + top: 0; +} diff --git a/kfet/templates/kfet/base_messages.html b/kfet/templates/kfet/base_messages.html index 440b8c10..3ac8512d 100644 --- a/kfet/templates/kfet/base_messages.html +++ b/kfet/templates/kfet/base_messages.html @@ -2,7 +2,7 @@
{% for message in messages %}
-
+
{% if 'safe' in message.tags %} {{ message|safe }} diff --git a/kfet/templates/kfet/history.html b/kfet/templates/kfet/history.html index ab1eef72..9e983079 100644 --- a/kfet/templates/kfet/history.html +++ b/kfet/templates/kfet/history.html @@ -14,7 +14,6 @@ - {% endblock %} @@ -30,7 +29,6 @@
opérations
-

Filtres

De
à
Caisses {{ filter_form.checkouts }}
From ce23eece6aff233f330eb4401a6e6d8eaa91c11f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 15 Apr 2017 13:03:01 +0200 Subject: [PATCH 53/90] Fix display on small screen devices. - Remove useless margin on small screens. - Better pills display on small screens. - Revert to transparent background for section title. --- kfet/static/kfet/css/index.css | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index d241e209..d9c44a60 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -154,6 +154,14 @@ textarea { display:block; } +.content-left .buttons ul.nav-pills { + margin-bottom:5px; +} + +.content-left .buttons ul.nav-pills li { + margin:0 0 -5px; +} + .content-left-top.frozen-account { background:#000FBA; color:#fff; @@ -202,14 +210,6 @@ textarea { padding-bottom:15px; } -.content-right-block > div:not(.buttons-title) { - background:#fff; -} - -.content-right-block-transparent > div:not(.buttons-title) { - background-color: transparent; -} - .content-right-block .buttons-title { position:absolute; top:8px; From 8622002e8df3f7c98a40b7f68fb45c5222c9e43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 17 Apr 2017 20:40:54 +0200 Subject: [PATCH 54/90] minor change --- cof/settings_dev.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cof/settings_dev.py b/cof/settings_dev.py index fe4fcdea..c4a46011 100644 --- a/cof/settings_dev.py +++ b/cof/settings_dev.py @@ -192,9 +192,7 @@ def show_toolbar(request): machine physique n'est pas forcément connue, et peut difficilement être mise dans les INTERNAL_IPS. """ - if not DEBUG: - return False - return True + return DEBUG DEBUG_TOOLBAR_CONFIG = { 'SHOW_TOOLBAR_CALLBACK': show_toolbar, From 0d8a613f28223e756f660caa2ba8c000ba3cc596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 21 Apr 2017 18:22:53 +0200 Subject: [PATCH 55/90] improve bda inscription form/view code --- bda/forms.py | 36 ++++++++++++++++++++++++++++++++---- bda/views.py | 50 +++++++++----------------------------------------- 2 files changed, 41 insertions(+), 45 deletions(-) diff --git a/bda/forms.py b/bda/forms.py index 3565bedf..c0417d1e 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -1,14 +1,42 @@ # -*- coding: utf-8 -*- -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - from django import forms +from django.forms.models import BaseInlineFormSet from django.utils import timezone + from bda.models import Attribution, Spectacle +class InscriptionInlineFormSet(BaseInlineFormSet): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # self.instance is a Participant object + tirage = self.instance.tirage + + # set once for all "spectacle" field choices + # - restrict choices to the spectacles of this tirage + # - force_choices avoid many db requests + spectacles = tirage.spectacle_set.select_related('location') + choices = [(sp.pk, str(sp)) for sp in spectacles] + self.force_choices('spectacle', choices) + + def force_choices(self, name, choices): + """Set choices of a field. + + As ModelChoiceIterator (default use to get choices of a + ModelChoiceField), it appends an empty selection if requested. + + """ + for form in self.forms: + field = form.fields[name] + if field.empty_label is not None: + field.choices = [('', field.empty_label)] + choices + else: + field.choices = choices + + class TokenForm(forms.Form): token = forms.CharField(widget=forms.widgets.Textarea()) diff --git a/bda/views.py b/bda/views.py index 6e0e73b6..00a1b300 100644 --- a/bda/views.py +++ b/bda/views.py @@ -32,6 +32,7 @@ from bda.models import ( from bda.algorithm import Algorithm from bda.forms import ( TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, SoldForm, + InscriptionInlineFormSet, ) @@ -157,60 +158,27 @@ def inscription(request, tirage_id): messages.error(request, "Le tirage n'est pas encore ouvert : " "ouverture le {:s}".format(opening)) return render(request, 'bda/resume-inscription-tirage.html', {}) + + participant, _ = ( + Participant.objects.select_related('tirage') + .get_or_create(user=request.user, tirage=tirage) + ) + if timezone.now() > tirage.fermeture: # Le tirage est fermé. - participant, _ = ( - Participant.objects - .get_or_create(user=request.user, tirage=tirage) - ) choices = participant.choixspectacle_set.order_by("priority") messages.error(request, " C'est fini : tirage au sort dans la journée !") return render(request, "bda/resume-inscription-tirage.html", {"choices": choices}) - def force_for(f, to_choices=None, **kwargs): - """Overrides choices for ModelChoiceField. - - Args: - f (models.Field): To render as forms.Field - to_choices (dict): If a key `f.name` exists, f._choices is set to - its value. - - """ - formfield = f.formfield(**kwargs) - if to_choices: - if f.name in to_choices: - choices = [('', '---------')] + to_choices[f.name] - formfield._choices = choices - return formfield - - # Restrict spectacles choices to spectacles for this tirage. - spectacles = ( - tirage.spectacle_set - .select_related('location') - ) - spectacles_field_choices = [(sp.pk, str(sp)) for sp in spectacles] - - # Allow for spectacle choices to be set once for all. - # Form display use 1 request instead of (#forms of formset * #spectacles). - # FIXME: Validation still generates too much requests... - formfield_callback = partial( - force_for, - to_choices={ - 'spectacle': spectacles_field_choices, - }, - ) BdaFormSet = inlineformset_factory( Participant, ChoixSpectacle, fields=("spectacle", "double_choice", "priority"), - formfield_callback=formfield_callback, - ) - participant, _ = ( - Participant.objects - .get_or_create(user=request.user, tirage=tirage) + formset=InscriptionInlineFormSet, ) + success = False stateerror = False if request.method == "POST": From 739990cdb698ae165c9bcfc7ed35bfc4b679ea05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 22 Apr 2017 01:17:23 +0200 Subject: [PATCH 56/90] Add total boxes to new inventory view + fix/clean - Add total boxes in cellar and bar to new inventory view. - On this view, table is "minified". - Revert background color for some templates. - Clean some margin (responsively). - Clean tab pills on account read. --- kfet/static/kfet/css/index.css | 59 +++++----- kfet/templates/kfet/account_read.html | 60 +++++----- kfet/templates/kfet/inventory_create.html | 133 ++++++++++++++-------- 3 files changed, 141 insertions(+), 111 deletions(-) diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index d9c44a60..04bb438e 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -114,22 +114,6 @@ textarea { padding: 0 !important; } -.panel-md-margin{ - background-color: white; - overflow:hidden; - padding-left: 15px; - padding-right: 15px; - padding-bottom: 15px; - padding-top: 1px; -} - -@media (min-width: 992px) { - .panel-md-margin{ - margin:8px; - background-color: white; - } -} - .col-content-left, .col-content-right { padding:0; } @@ -194,20 +178,18 @@ textarea { text-align:center; } - @media (min-width: 768px) { .content-right { - margin: 0 15px; + margin: 15px; } } .content-right-block { - padding-bottom:5px; position:relative; } -.content-right-block:last-child { - padding-bottom:15px; +.content-right-block > div:not(.buttons-title) { + background: #fff; } .content-right-block .buttons-title { @@ -229,9 +211,8 @@ textarea { .content-right-block h3 { border-bottom: 1px solid #c8102e; - margin: 20px 15px 15px; - padding-bottom: 10px; - padding-left: 20px; + margin: 0px 15px 15px; + padding: 20px 20px 10px; font-size:25px; } @@ -239,12 +220,18 @@ textarea { * Pages tableaux seuls */ -.content-center > div { +.content-center { background:#fff; } +@media (min-width: 992px) { + .content-center { + margin: 15px; + } +} + .content-center tbody tr:not(.section) td { - padding:0px 5px !important; + padding:0px 5px; } .content-center .table .form-control { @@ -252,7 +239,15 @@ textarea { height:28px; margin:3px 0px; } - .content-center .auth-form { + +.content-center .table-condensed input.form-control { + margin: 0 !important; + border-top: 0; + border-bottom: 0; + border-radius: 0; +} + +.content-center .auth-form { margin:15px; } @@ -577,15 +572,21 @@ thead .tooltip { /* Inventaires */ +#inventoryform input[type=number] { + text-align: center; +} + .inventory_modified { background:rgba(236,100,0,0.15); } .stock_diff { padding-left: 5px; - color:#C8102E; + color:#C8102E; } .inventory_update { - display:none; + display: none; + width: 50px; + margin: 0 auto; } diff --git a/kfet/templates/kfet/account_read.html b/kfet/templates/kfet/account_read.html index b55f2b99..95df85a0 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -23,7 +23,7 @@ $(document).ready(function() { "{% url 'kfet.account.stat.balance.list' trigramme=account.trigramme %}", $("#stat_balance") ); - }); +}); {% endif %} {% endblock %} @@ -55,39 +55,33 @@ $(document).ready(function() {
{% include "kfet/base_messages.html" %}
-
-
- {% if account.user == request.user %} -
-
-

Statistiques

-
-

Ma balance

-
-

Ma consommation

-
-
-
-
- {% endif %} - {% if addcosts %} -

Gagné des majorations

-
-
    - {% for addcost in addcosts %} -
  • {{ addcost.date|date:'l j F' }}: +{{ addcost.sum_addcosts }}€
  • - {% endfor %} -
-
- {% endif %} -

Historique

-
- {% if account.user == request.user %} -
-
+
+ {% if account.user == request.user %} +
+

Statistiques

+
+

Ma balance

+
+

Ma consommation

+
+
+
{% endif %} -
-
+
+ {% if addcosts %} +

Gagné des majorations

+
+
    + {% for addcost in addcosts %} +
  • {{ addcost.date|date:'l j F' }}: +{{ addcost.sum_addcosts }}€
  • + {% endfor %} +
+
+ {% endif %} +

Historique

+
+
+
diff --git a/kfet/templates/kfet/inventory_create.html b/kfet/templates/kfet/inventory_create.html index d8109f8e..d1fe6e05 100644 --- a/kfet/templates/kfet/inventory_create.html +++ b/kfet/templates/kfet/inventory_create.html @@ -13,24 +13,25 @@ {% block content %} {% include 'kfet/base_messages.html' %} -
-
-
- - - - - - - - - - - - - - - {% for form in formset %} +
+
+
+ +
ArticleQuantité par caisseStock ThéoriqueCaisses en réserveCaisses en arrièreVracStock totalCompte terminé
+ + + + + + + + + + + + + + {% for form in formset %} {% ifchanged form.category %} @@ -41,42 +42,68 @@ {{ form.article }} - - + + + + + - - - - - {% endfor %} - -
ArticleQuantité par caisseStock théoriqueCaisses en réserveCaisses en arrièreVracStock totalCompte terminé
{{ form.category_name }}{{ form.name }} {{ form.box_capacity }}{{ form.stock_old }} -
-
- +
+ {{ form.stock_old }} + + + + + + + + {{ form.stock_new | attr:"readonly"| add_class:"form-control" }} + +
+ +
+
+
-
-
-
-
-
{{ form.stock_new | attr:"readonly"| add_class:"form-control" }}
-
-
- {{ formset.management_form }} - {% if not perms.kfet.add_inventory %} -
- {% include "kfet/form_authentication_snippet.html" %} -
- {% endif %} - - {% csrf_token %} -
+ {% endfor %} + + Totaux + + + + + + + + {{ formset.management_form }} + {% if not perms.kfet.add_inventory %} +
+ {% include "kfet/form_authentication_snippet.html" %} +
+ {% endif %} + + {% csrf_token %} + +
{% endblock %} diff --git a/kfet/templates/kfet/settings_update.html b/kfet/templates/kfet/settings_update.html index fdd5f5d4..b5f46c23 100644 --- a/kfet/templates/kfet/settings_update.html +++ b/kfet/templates/kfet/settings_update.html @@ -5,21 +5,6 @@ {% block content %} -{% include "kfet/base_messages.html" %} - -
-
-
-
- {% csrf_token %} - {% include 'kfet/form_snippet.html' with form=form %} - {% if not perms.kfet.change_settings %} - {% include 'kfet/form_authentication_snippet.html' %} - {% endif %} - {% include 'kfet/form_submit_snippet.html' with value="Mettre à jour" %} -
-
-
-
+{% include "kfet/base_form.html" with authz=perms.kfet.change_settings submit_text="Mettre à jour"%} {% endblock %} diff --git a/kfet/templates/kfet/supplier_form.html b/kfet/templates/kfet/supplier_form.html index 168f74d9..434b9382 100644 --- a/kfet/templates/kfet/supplier_form.html +++ b/kfet/templates/kfet/supplier_form.html @@ -1,27 +1,10 @@ {% extends 'kfet/base.html' %} -{% load widget_tweaks %} -{% load staticfiles %} {% block title %}Fournisseur - Modification{% endblock %} {% block content-header-title %}Fournisseur - Modification{% endblock %} {% block content %} -{% include 'kfet/base_messages.html' %} - -
-
-
-
- {% csrf_token %} - {% include 'kfet/form_snippet.html' with form=form %} - {% if not perms.kfet.change_supplier %} - {% include 'kfet/form_authentication_snippet.html' %} - {% endif %} - {% include 'kfet/form_submit_snippet.html' with value="Mettre à jour" %} -
-
-
-
+{% include 'kfet/base_form.html' with authz=perms.kfet.change_supplier submit_text="Mettre à jour" %} {% endblock %} diff --git a/kfet/views.py b/kfet/views.py index 60dbb44b..7b1674c4 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -85,6 +85,8 @@ def login_genericteam(request): user = authenticate(username="kfet_genericteam", token=token.token) login(request, user) + messages.success(request, "Connecté en utilisateur partagé") + if need_cas_logout: # Vue de déconnexion de CAS return logout_cas @@ -514,6 +516,10 @@ def account_update(request, trigramme): return render(request, "kfet/account_update.html", { 'account': account, + 'forms': [ + user_form, cof_form, account_form, group_form, pwd_form, + negative_form, + ], 'account_form': account_form, 'cof_form': cof_form, 'user_form': user_form, From 1a661c1fd3d784a2c784ab2ff8d90442871f004a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 18 May 2017 20:29:29 +0200 Subject: [PATCH 69/90] revert --- kfet/views.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/kfet/views.py b/kfet/views.py index 7b1674c4..fa94bb35 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -516,10 +516,6 @@ def account_update(request, trigramme): return render(request, "kfet/account_update.html", { 'account': account, - 'forms': [ - user_form, cof_form, account_form, group_form, pwd_form, - negative_form, - ], 'account_form': account_form, 'cof_form': cof_form, 'user_form': user_form, From e9073e22654a9908c86854f9bc04ad270a4429bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 18 May 2017 21:41:23 +0200 Subject: [PATCH 70/90] Improve multiple select inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit + Group edition form gains success message, is prettier + Fix: K-Fêt prefix for group name on this form --- kfet/static/kfet/css/index.css | 13 +++++ kfet/templates/kfet/account_group_form.html | 59 +++++++++++++++------ kfet/templates/kfet/base_form.html | 2 +- kfet/templates/kfet/history.html | 3 ++ kfet/views.py | 2 +- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index 50d2cae8..4fc02014 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -595,3 +595,16 @@ thead .tooltip { width: 50px; margin: 0 auto; } + +/* Multiple select customizations */ + +.ms-choice { + height: 34px !important; + line-height: 34px !important; + border: 1px solid #ccc !important; + box-shadow: inset 0 1px 1px rgba(0,0,0,.075) !important; +} + +.ms-choice > div { + top: 4px !important; +} diff --git a/kfet/templates/kfet/account_group_form.html b/kfet/templates/kfet/account_group_form.html index e37bdd89..00e7809b 100644 --- a/kfet/templates/kfet/account_group_form.html +++ b/kfet/templates/kfet/account_group_form.html @@ -1,37 +1,62 @@ {% extends 'kfet/base.html' %} {% load staticfiles %} +{% load widget_tweaks %} {% block extra_head %} {% endblock %} +{% block title %}Permissions - Édition{% endblock %} +{% block content-header-title %}Modification des permissions{% endblock %} + {% block content %} -
- {% csrf_token %} -
- {{ form.name.errors }} - {{ form.name.label_tag }} -
- K-Fêt - {{ form.name }} +{% include "kfet/base_messages.html" %} + +
+
+
+ + {% csrf_token %} +
+ +
+
+ K-Fêt + {{ form.name|add_class:"form-control" }} +
+ {% if form.name.errors %}{{ form.name.errors }}{% endif %} + {% if form.name.help_text %}{{ form.name.help_text }}{% endif %} +
+
+ {% include "kfet/form_field_snippet.html" with field=form.permissions %} + {% if not perms.kfet.manage_perms %} + {% include "kfet/form_authentication_snippet.html" %} + {% endif %} + {% include "kfet/form_submit_snippet.html" with value="Enregistrer" %} +
-
- {{ form.permissions.errors }} - {{ form.permissions.label_tag }} - {{ form.permissions }} -
- - +
diff --git a/kfet/templates/kfet/base_form.html b/kfet/templates/kfet/base_form.html index b83e888a..ba6a84ac 100644 --- a/kfet/templates/kfet/base_form.html +++ b/kfet/templates/kfet/base_form.html @@ -3,7 +3,7 @@
-
+ {% csrf_token %} {% include "kfet/form_snippet.html" %} {% if not authz %} diff --git a/kfet/templates/kfet/history.html b/kfet/templates/kfet/history.html index 9e983079..add461ab 100644 --- a/kfet/templates/kfet/history.html +++ b/kfet/templates/kfet/history.html @@ -128,6 +128,9 @@ $(document).ready(function() { $("select").multipleSelect({ width: '100%', filter: true, + allSelected: " ", + selectAllText: "Tout-te-s", + countSelected: "# sur %" }); $("input").on('dp.change change', function() { diff --git a/kfet/views.py b/kfet/views.py index fa94bb35..0cce165e 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -539,7 +539,7 @@ class AccountGroupCreate(SuccessMessageMixin, CreateView): success_message = 'Nouveau groupe : %(name)s' success_url = reverse_lazy('kfet.account.group') -class AccountGroupUpdate(UpdateView): +class AccountGroupUpdate(SuccessMessageMixin, UpdateView): queryset = Group.objects.filter(name__icontains='K-Fêt') template_name = 'kfet/account_group_form.html' form_class = GroupForm From ae270656264da08afe911d3cd3ffa551a7ce117c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 May 2017 13:42:41 +0200 Subject: [PATCH 71/90] Group permissions select multiple -> checkboxes - Add handler for CheckboxSelectMultiple in form_field_snippet.html. - Add template filter "widget_type" to get widget class name. - Group permissions selection becomes easier. --- kfet/forms.py | 23 +++++++++++++++++---- kfet/static/kfet/css/index.css | 7 +++++++ kfet/templates/kfet/account_group_form.html | 10 --------- kfet/templates/kfet/form_field_snippet.html | 14 ++++++++++++- kfet/templatetags/kfet_tags.py | 7 +++++-- 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/kfet/forms.py b/kfet/forms.py index f89b8f08..296ed4ae 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.contrib.auth.models import User, Group, Permission from django.contrib.contenttypes.models import ContentType -from django.forms import modelformset_factory +from django.forms import modelformset_factory, widgets from django.utils import timezone from djconfig.forms import ConfigForm @@ -151,10 +151,25 @@ class UserGroupForm(forms.ModelForm): model = User fields = ['groups'] + +class KFetPermissionsField(forms.ModelMultipleChoiceField): + + def __init__(self, *args, **kwargs): + queryset = Permission.objects.filter( + content_type__in=ContentType.objects.filter(app_label="kfet"), + ) + super().__init__( + queryset=queryset, + widget=widgets.CheckboxSelectMultiple, + *args, **kwargs + ) + + def label_from_instance(self, obj): + return obj.name + + class GroupForm(forms.ModelForm): - permissions = forms.ModelMultipleChoiceField( - queryset= Permission.objects.filter(content_type__in= - ContentType.objects.filter(app_label='kfet'))) + permissions = KFetPermissionsField() def clean_name(self): name = self.cleaned_data['name'] diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index 4fc02014..036e725e 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -608,3 +608,10 @@ thead .tooltip { .ms-choice > div { top: 4px !important; } + +/* Checkbox select multiple */ + +.checkbox-select-multiple label { + font-weight: normal; + margin-bottom: 0; +} diff --git a/kfet/templates/kfet/account_group_form.html b/kfet/templates/kfet/account_group_form.html index 00e7809b..52b99934 100644 --- a/kfet/templates/kfet/account_group_form.html +++ b/kfet/templates/kfet/account_group_form.html @@ -42,16 +42,6 @@ {% endblock %} -{% block content-header-title %}Création d'un compte{% endblock %} +{% block main-class %}content-form{% endblock %} -{% block content %} +{% block main-content %} -{% include 'kfet/base_messages.html' %} - -
-
-
- - {% csrf_token %} -
- {{ trigramme_form.trigramme.errors }} - {{ trigramme_form.trigramme }} -
-
-

Les mots contenant des caractères non alphanumériques seront ignorés

- -
-
-
-
-
- {% include 'kfet/account_create_form.html' %} -
- {% if not perms.kfet.add_account %} - {% include 'kfet/form_authentication_snippet.html' %} - {% endif %} -
- -
+
+
+

Les mots contenant des caractères non alphanumériques seront ignorés

+ +
+
+
+
+
+ {% include 'kfet/account_create_form.html' %} +
+ {% if not perms.kfet.add_account %} + {% include 'kfet/form_authentication_snippet.html' %} + {% endif %} +
+ + {% endblock %} -{% block content-header-title %}Création d'un compte{% endblock %} +{% block main-class %}content-form{% endblock %} -{% block content %} +{% block main-content %} -
-
- {% include 'kfet/base_messages.html' %} -
- -
+
+
+ +
+
+
+
+
+ {% include 'kfet/account_create_form.html' %} +
+ {% if not perms.kfet.add_account %} + {% include 'kfet/form_authentication_snippet.html' %} + {% endif %} +
+ {% endblock %} -{% block title %}Informations sur l'article {{ article }}{% endblock %} -{% block content-header-title %}Article - {{ article.name }}{% endblock %} +{% block title %}Article - {{ article.name }}{% endblock %} +{% block header-title %}Informations sur l'article {{ article.name }}{% endblock %} -{% block content %} +{% block fixed-content %} -
-
-
-
-
{{ article.name }}
-
{{ article.category }}
-
-
Prix (hors réduc.): {{ article.price }}€
-
Stock: {{ article.stock }}
-
En vente: {{ article.is_sold | yesno:"Oui,Non" }}
-
Affiché: {{ article.hidden | yesno:"Non,Oui" }}
-
-
- -
+
+
{{ article.name }}
+
{{ article.category }}
+
+
Prix (hors réduc.): {{ article.price }}€
+
Stock: {{ article.stock }}
+
En vente: {{ article.is_sold | yesno:"Oui,Non" }}
+
Affiché: {{ article.hidden | yesno:"Non,Oui" }}
-
- {% include 'kfet/base_messages.html' %} -
-
-

Historique

-
-
-

Inventaires

- - - - - - - - - - {% for inventoryart in inventoryarts %} - - - - - - {% endfor %} - -
DateStockErreur
{{ inventoryart.inventory.at }}{{ inventoryart.stock_new }}{{ inventoryart.stock_error }}
-
-
-

Prix fournisseurs

- - - - - - - - - - - - {% for supplierart in supplierarts %} - - - - - - - - {% endfor %} - -
DateFournisseurHTTVADroits
{{ supplierart.at }}{{ supplierart.supplier.name }}{{ supplierart.price_HT }}{{ supplierart.TVA }}{{ supplierart.rights }}
-
-
-
-
-

Statistiques

-
-
-
-

Ventes de {{ article.name }}

-
-
-
-
+
+ + +{% endblock %} + +{% block main-content %} + +
+

Historique

+
+
+

Inventaires

+ + + + + + + + + + {% for inventoryart in inventoryarts %} + + + + + + {% endfor %} + +
DateStockErreur
{{ inventoryart.inventory.at }}{{ inventoryart.stock_new }}{{ inventoryart.stock_error }}
+
+
+

Prix fournisseurs

+
+ + + + + + + + + + + + {% for supplierart in supplierarts %} + + + + + + + + {% endfor %} + +
DateFournisseurHTTVADroits
{{ supplierart.at }}{{ supplierart.supplier.name }}{{ supplierart.price_HT }}{{ supplierart.TVA }}{{ supplierart.rights }}
+
+
+
+

Statistiques

+
+

Ventes

+
diff --git a/kfet/templates/kfet/article_update.html b/kfet/templates/kfet/article_update.html index 65ccec3b..d451df94 100644 --- a/kfet/templates/kfet/article_update.html +++ b/kfet/templates/kfet/article_update.html @@ -1,9 +1,11 @@ -{% extends 'kfet/base.html' %} +{% extends "kfet/base_col_1.html" %} -{% block title %}Édition de l'article {{ article.name }}{% endblock %} -{% block content-header-title %}Article {{ article.name }} - Édition{% endblock %} +{% block title %}{{ article.name }} - Édition{% endblock %} +{% block header-title %}Édition de l'article {{ article.name }}{% endblock %} -{% block content %} +{% block main-class %}content-form{% endblock %} + +{% block main-content %} {% include "kfet/base_form.html" with authz=perms.kfet.change_article submit_text="Mettre à jour"%} diff --git a/kfet/templates/kfet/base.html b/kfet/templates/kfet/base.html index 173a5fb7..da37abae 100644 --- a/kfet/templates/kfet/base.html +++ b/kfet/templates/kfet/base.html @@ -30,12 +30,12 @@ {% include "kfet/base_nav.html" %}
- {% block content-header %} -
-
-

{% block content-header-title %}{% endblock %}

-
+ {% block header %} +
+
+

{% block header-title %}{% endblock %}

+
{% endblock %} {% block content %}{% endblock %} {% include "kfet/base_footer.html" %} diff --git a/kfet/templates/kfet/base_col_1.html b/kfet/templates/kfet/base_col_1.html new file mode 100644 index 00000000..a4c26b82 --- /dev/null +++ b/kfet/templates/kfet/base_col_1.html @@ -0,0 +1,14 @@ +{% extends "kfet/base.html" %} + +{% block content %} + +
+
+ {% include "kfet/base_messages.html" %} +
+ {% block main-content %}{% endblock %} +
+
+
+ +{% endblock %} diff --git a/kfet/templates/kfet/base_col_2.html b/kfet/templates/kfet/base_col_2.html new file mode 100644 index 00000000..58c36d14 --- /dev/null +++ b/kfet/templates/kfet/base_col_2.html @@ -0,0 +1,19 @@ +{% extends "kfet/base.html" %} + +{% block content %} + +
+
+
+ {% block fixed-content %}{% endblock %} +
+
+
+ {% include "kfet/base_messages.html" %} +
+ {% block main-content %}{% endblock %} +
+
+
+ +{% endblock %} diff --git a/kfet/templates/kfet/base_form.html b/kfet/templates/kfet/base_form.html index 9fe79e32..1ac4c81b 100644 --- a/kfet/templates/kfet/base_form.html +++ b/kfet/templates/kfet/base_form.html @@ -1,17 +1,10 @@ {% load kfet_tags %} -
-
- {% include "kfet/base_messages.html" %} -
-
- {% csrf_token %} - {% include "kfet/form_snippet.html" %} - {% if not authz %} - {% include "kfet/form_authentication_snippet.html" %} - {% endif %} - {% include "kfet/form_submit_snippet.html" with value=submit_text %} -
-
-
-
+
+ {% csrf_token %} + {% include "kfet/form_snippet.html" %} + {% if not authz %} + {% include "kfet/form_authentication_snippet.html" %} + {% endif %} + {% include "kfet/form_submit_snippet.html" with value=submit_text %} +
diff --git a/kfet/templates/kfet/category.html b/kfet/templates/kfet/category.html index 5393bf59..b7797c49 100644 --- a/kfet/templates/kfet/category.html +++ b/kfet/templates/kfet/category.html @@ -1,52 +1,46 @@ -{% extends 'kfet/base.html' %} +{% extends "kfet/base_col_2.html" %} {% block title %}Categories d'articles{% endblock %} -{% block content-header-title %}Categories d'articles{% endblock %} +{% block header-title %}Categories d'articles{% endblock %} -{% block content %} +{% block fixed-content %} -
-
-
-
-
{{ categories|length }}
-
catégorie{{ categories|length|pluralize }}
-
-
-
-
- {% include 'kfet/base_messages.html' %} -
-
-

Liste des catégories

-
- - - - - - - - - - - {% for category in categories %} - - - - - - - {% endfor %} - -
NomNombre d'articlesPeut être majorée
- - - - {{ category.name }}{{ category.articles.all|length }}{{ category.has_addcost | yesno:"Oui,Non"}}
-
-
-
+
+
{{ categories|length }}
+
catégorie{{ categories|length|pluralize }}
+
+ +{% endblock %} + +{% block main-content %} + +
+

Liste des catégories

+
+ + + + + + + + + + + {% for category in categories %} + + + + + + + {% endfor %} + +
NomNombre d'articlesPeut être majorée
+ + + + {{ category.name }}{{ category.articles.all|length }}{{ category.has_addcost | yesno:"Oui,Non"}}
diff --git a/kfet/templates/kfet/category_update.html b/kfet/templates/kfet/category_update.html index af213e71..c535a31d 100644 --- a/kfet/templates/kfet/category_update.html +++ b/kfet/templates/kfet/category_update.html @@ -1,9 +1,11 @@ -{% extends 'kfet/base.html' %} +{% extends "kfet/base_col_1.html" %} -{% block title %}Édition de la catégorie {{ category.name }}{% endblock %} -{% block content-header-title %}Catégorie {{ category.name }} - Édition{% endblock %} +{% block title %}{{ articlecategory.name }} - Édition{% endblock %} +{% block header-title %}Édition de la catégorie {{ articlecategory.name }}{% endblock %} -{% block content %} +{% block main-class %}content-form{% endblock %} + +{% block main-content %} {% include "kfet/base_form.html" with authz=perms.kfet.edit_articlecategory submit_text="Enregistrer"%} diff --git a/kfet/templates/kfet/checkout.html b/kfet/templates/kfet/checkout.html index fb2d10a7..329b8bef 100644 --- a/kfet/templates/kfet/checkout.html +++ b/kfet/templates/kfet/checkout.html @@ -1,59 +1,53 @@ -{% extends "kfet/base.html" %} +{% extends "kfet/base_col_2.html" %} -{% block title %}Liste des caisses{% endblock %} -{% block content-header-title %}Caisses{% endblock %} +{% block title %}Caisses{% endblock %} +{% block header-title %}Caisses{% endblock %} -{% block content %} +{% block fixed-content %} -
-
-
-
-
{{ checkouts|length }}
-
caisse{{ checkouts|length|pluralize }}
-
- -
-
-
- {% include 'kfet/base_messages.html' %} -
-
-

Liste des caisses

-
- - - - - - - - - - - - - {% for checkout in checkouts %} - - - - - - - - - {% endfor %} - -
NomBalanceDéb. valid.Fin valid.Protégée
- - - - {{ checkout.name }}{{ checkout.balance}}€{{ checkout.valid_from }}{{ checkout.valid_to }}{{ checkout.is_protected }}
-
-
-
+
+
{{ checkouts|length }}
+
caisse{{ checkouts|length|pluralize }}
+
+ + +{% endblock %} + +{% block main-content %} + +
+

Liste des caisses

+
+ + + + + + + + + + + + + {% for checkout in checkouts %} + + + + + + + + + {% endfor %} + +
NomBalanceDéb. valid.Fin valid.Protégée
+ + + + {{ checkout.name }}{{ checkout.balance}}€{{ checkout.valid_from }}{{ checkout.valid_to }}{{ checkout.is_protected }}
diff --git a/kfet/templates/kfet/checkout_create.html b/kfet/templates/kfet/checkout_create.html index 0f254f65..bed1b6ef 100644 --- a/kfet/templates/kfet/checkout_create.html +++ b/kfet/templates/kfet/checkout_create.html @@ -1,28 +1,13 @@ -{% extends "kfet/base.html" %} +{% extends "kfet/base_col_1.html" %} {% block extra_head %}{{ form.media }}{% endblock %} {% block title %}Nouvelle caisse{% endblock %} -{% block content-header-title %}Création d'une caisse{% endblock %} +{% block header-title %}Création d'une caisse{% endblock %} -{% block content %} +{% block main-class %}content-form{% endblock %} +{% block main-content %} -{% include 'kfet/base_messages.html' %} -
- {% csrf_token %} - {{ form.non_field_errors}} - {% for field in form %} - {{ field.errors }} - {{ field.label_tag }} -
{{ field }}
- {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} - {% endfor %} - {% if not perms.kfet.add_checkout %} - - {% endif %} - -
+{% include "kfet/base_form.html" with authz=perms.kfet.add_checkout submit_text="Enregistrer" %} {% block extra_head %}{% endblock %} diff --git a/gestioncof/templates/gestioncof/banner_update.html b/gestioncof/templates/gestioncof/banner_update.html new file mode 100644 index 00000000..b2432eae --- /dev/null +++ b/gestioncof/templates/gestioncof/banner_update.html @@ -0,0 +1,23 @@ +{% extends "base_title.html" %} +{% load bootstrap %} +{% load i18n %} + +{% block page_size %}col-sm-8{%endblock%} + +{% block realcontent %} +

{% trans "Global configuration" %}

+
+
+ {% csrf_token %} + + {% for field in form %} + {{ field | bootstrap }} + {% endfor %} + +
+
+ +
+
+{% endblock %} diff --git a/gestioncof/templates/gestioncof/base_header.html b/gestioncof/templates/gestioncof/base_header.html index a7e29eb7..21441875 100644 --- a/gestioncof/templates/gestioncof/base_header.html +++ b/gestioncof/templates/gestioncof/base_header.html @@ -16,6 +16,14 @@

{% if user.first_name %}{{ user.first_name }}{% else %}{{ user.username }}{% endif %}, {% if user.profile.is_cof %}au COF{% else %}non-COF{% endif %}

+ +{% if config.gestion_banner %} + +{% endif %} + {% if messages %} {% for message in messages %}
diff --git a/gestioncof/views.py b/gestioncof/views.py index 6a728ea6..f98c51df 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -10,6 +10,8 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth.views import login as django_login_view from django.contrib.auth.models import User from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse_lazy +from django.views.generic import FormView from django.utils import timezone from django.contrib import messages @@ -21,10 +23,11 @@ from gestioncof.models import EventCommentField, EventCommentValue, \ CalendarSubscription from gestioncof.models import CofProfile, Club from gestioncof.decorators import buro_required, cof_required -from gestioncof.forms import UserProfileForm, EventStatusFilterForm, \ - SurveyForm, SurveyStatusFilterForm, RegistrationUserForm, \ - RegistrationProfileForm, EventForm, CalendarForm, EventFormset, \ - RegistrationPassUserForm, ClubsForm +from gestioncof.forms import ( + UserProfileForm, EventStatusFilterForm, SurveyForm, SurveyStatusFilterForm, + RegistrationUserForm, RegistrationProfileForm, EventForm, CalendarForm, + EventFormset, RegistrationPassUserForm, ClubsForm, GestioncofConfigForm +) from bda.models import Tirage, Spectacle @@ -758,3 +761,18 @@ def calendar_ics(request, token): response = HttpResponse(content=vcal.to_ical()) response['Content-Type'] = "text/calendar" return response + + +class ConfigUpdate(FormView): + form_class = GestioncofConfigForm + template_name = "gestioncof/banner_update.html" + success_url = reverse_lazy("home") + + def dispatch(self, request, *args, **kwargs): + if request.user is None or not request.user.is_superuser: + raise Http404 + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + form.save() + return super().form_valid(form) diff --git a/kfet/static/kfet/css/kpsul.css b/kfet/static/kfet/css/kpsul.css index ba88e433..da1cffa1 100644 --- a/kfet/static/kfet/css/kpsul.css +++ b/kfet/static/kfet/css/kpsul.css @@ -18,6 +18,17 @@ input[type=number]::-webkit-outer-spin-button { 100% { background: yellow; } } +/* Announcements banner */ + +#banner { + background-color: #d86b01; + width: 100%; + text-align: center; + padding: 10px; + color: white; + font-size: larger; +} + /* * Top row */ diff --git a/kfet/templates/kfet/base_messages.html b/kfet/templates/kfet/base_messages.html index 440b8c10..3af19b31 100644 --- a/kfet/templates/kfet/base_messages.html +++ b/kfet/templates/kfet/base_messages.html @@ -1,3 +1,10 @@ +{% if config.gestion_banner %} + +{% endif %} + {% if messages %}
{% for message in messages %}