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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] =?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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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 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 50/61] 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 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 51/61] 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 52/61] 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 2aee43e01a67d07f0392f230d83f6e5db10f2de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Mon, 24 Apr 2017 21:27:01 +0100 Subject: [PATCH 53/61] Add more configuration options for redis - `REDIS_HOST` can be specified in the secrets - Two new secrets: `REDIS_PASSWD` and `REDIS_DB` --- cof/settings/common.py | 8 ++++++-- cof/settings/secret_example.py | 3 +++ provisioning/bootstrap.sh | 10 +++++----- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/cof/settings/common.py b/cof/settings/common.py index 93b11dae..612d52fc 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -22,7 +22,8 @@ except KeyError: # Other secrets try: from .secret import ( - SECRET_KEY, RECAPTCHA_PUBLIC_KEY, RECAPTCHA_PRIVATE_KEY, ADMINS + SECRET_KEY, RECAPTCHA_PUBLIC_KEY, RECAPTCHA_PRIVATE_KEY, ADMINS, + REDIS_PASSWD, REDIS_DB, REDIS_HOST ) except ImportError: raise RuntimeError("Secrets missing") @@ -159,7 +160,10 @@ CHANNEL_LAYERS = { "default": { "BACKEND": "asgi_redis.RedisChannelLayer", "CONFIG": { - "hosts": [(os.environ.get("REDIS_HOST", "localhost"), 6379)], + "hosts": [( + "redis://:{}@{}:6379/{}" + .format(REDIS_PASSWD, REDIS_HOST, REDIS_DB) + )], }, "ROUTING": "cof.routing.channel_routing", } diff --git a/cof/settings/secret_example.py b/cof/settings/secret_example.py index 36a8e932..3dc5de4b 100644 --- a/cof/settings/secret_example.py +++ b/cof/settings/secret_example.py @@ -1,4 +1,7 @@ SECRET_KEY = 'q()(zn4m63i%5cp4)f+ww4-28_w+ly3q9=6imw2ciu&_(5_4ah' RECAPTCHA_PUBLIC_KEY = "DUMMY" RECAPTCHA_PRIVATE_KEY = "DUMMY" +REDIS_PASSWD = "dummy" +REDIS_DB = 0 +REDIS_HOST = "127.0.0.1" ADMINS = None diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index c8f73ab6..33ae8308 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -23,6 +23,9 @@ apt-get install -y mysql-server mysql -uroot -p$DBPASSWD -e "CREATE DATABASE $DBNAME; GRANT ALL PRIVILEGES ON $DBNAME.* TO '$DBUSER'@'localhost' IDENTIFIED BY '$DBPASSWD'" mysql -uroot -p$DBPASSWD -e "GRANT ALL PRIVILEGES ON test_$DBNAME.* TO '$DBUSER'@'localhost'" +# Configuration de redis +echo "requirepass dummy" >> /etc/redis/redis.conf + # Installation et configuration d'Apache apt-get install -y apache2 a2enmod proxy proxy_http proxy_wstunnel headers @@ -36,7 +39,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: Mon, 24 Apr 2017 21:52:40 +0100 Subject: [PATCH 54/61] Fix settings in the provisioning script --- provisioning/bootstrap.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index 33ae8308..1e576a65 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -25,6 +25,7 @@ mysql -uroot -p$DBPASSWD -e "GRANT ALL PRIVILEGES ON test_$DBNAME.* TO '$DBUSER' # Configuration de redis echo "requirepass dummy" >> /etc/redis/redis.conf +service redis restart # Installation et configuration d'Apache apt-get install -y apache2 @@ -39,7 +40,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, 25 Apr 2017 20:23:21 +0100 Subject: [PATCH 55/61] Add REDIS_PORT to the settings and secrets --- cof/settings/common.py | 7 ++++--- cof/settings/secret_example.py | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cof/settings/common.py b/cof/settings/common.py index 612d52fc..261760d6 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -23,7 +23,7 @@ except KeyError: try: from .secret import ( SECRET_KEY, RECAPTCHA_PUBLIC_KEY, RECAPTCHA_PRIVATE_KEY, ADMINS, - REDIS_PASSWD, REDIS_DB, REDIS_HOST + REDIS_PASSWD, REDIS_DB, REDIS_HOST, REDIS_PORT ) except ImportError: raise RuntimeError("Secrets missing") @@ -161,8 +161,9 @@ CHANNEL_LAYERS = { "BACKEND": "asgi_redis.RedisChannelLayer", "CONFIG": { "hosts": [( - "redis://:{}@{}:6379/{}" - .format(REDIS_PASSWD, REDIS_HOST, REDIS_DB) + "redis://:{passwd}@{host}:{port}/{db}" + .format(passwd=REDIS_PASSWD, host=REDIS_HOST, + port=REDIS_PORT, db=REDIS_DB) )], }, "ROUTING": "cof.routing.channel_routing", diff --git a/cof/settings/secret_example.py b/cof/settings/secret_example.py index 3dc5de4b..eeb5271c 100644 --- a/cof/settings/secret_example.py +++ b/cof/settings/secret_example.py @@ -2,6 +2,7 @@ SECRET_KEY = 'q()(zn4m63i%5cp4)f+ww4-28_w+ly3q9=6imw2ciu&_(5_4ah' RECAPTCHA_PUBLIC_KEY = "DUMMY" RECAPTCHA_PRIVATE_KEY = "DUMMY" REDIS_PASSWD = "dummy" +REDIS_PORT = 6379 REDIS_DB = 0 REDIS_HOST = "127.0.0.1" ADMINS = None From fb4258f821b20db969935d55dcff55d112b842eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Tue, 25 Apr 2017 20:23:51 +0100 Subject: [PATCH 56/61] Set the redis passwd properly in bootstrap.sh --- provisioning/bootstrap.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index 1e576a65..38efdfb5 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -24,8 +24,9 @@ mysql -uroot -p$DBPASSWD -e "CREATE DATABASE $DBNAME; GRANT ALL PRIVILEGES ON $D mysql -uroot -p$DBPASSWD -e "GRANT ALL PRIVILEGES ON test_$DBNAME.* TO '$DBUSER'@'localhost'" # Configuration de redis -echo "requirepass dummy" >> /etc/redis/redis.conf -service redis restart +REDIS_PASSWD="dummy" +redis-cli CONFIG SET requirepass $REDIS_PASSWD +redis-cli -a $REDIS_PASSWD CONFIG REWRITE # Installation et configuration d'Apache apt-get install -y apache2 From 6cdb79198935191074cd0cfa1c82f2ee7b8c286c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Wed, 10 May 2017 12:39:56 +0200 Subject: [PATCH 57/61] fix class name conflicts --- bda/admin.py | 57 ++++++++++++++++++++++------------------------------ 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/bda/admin.py b/bda/admin.py index 02eebad5..0cc66d43 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -29,55 +29,46 @@ class ChoixSpectacleInline(admin.TabularInline): sortable_field_name = "priority" -class AttributionAdminForm(forms.ModelForm): +class AttributionTabularAdminForm(forms.ModelForm): + listing = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['spectacle'].queryset = ( - Spectacle.objects - .select_related('location') - ) + spectacles = Spectacle.objects.select_related('location') + if self.listing is not None: + spectacles = spectacles.filter(listing=self.listing) + self.fields['spectacle'].queryset = spectacles -class AttributionNoListingAdminForm(AttributionAdminForm): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['spectacle'].queryset = ( - self.fields['spectacle'].queryset - .filter(listing=False) - ) +class WithoutListingAttributionTabularAdminForm(AttributionTabularAdminForm): + listing = False -class AttributionListingAdminForm(AttributionAdminForm): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['spectacle'].queryset = ( - self.fields['spectacle'].queryset - .filter(listing=True) - ) +class WithListingAttributionTabularAdminForm(AttributionTabularAdminForm): + listing = True -class AttributionInlineListing(admin.TabularInline): +class AttributionInline(admin.TabularInline): model = Attribution extra = 0 - form = AttributionListingAdminForm + listing = None def get_queryset(self, request): qs = super().get_queryset(request) - return qs.filter(spectacle__listing=True) + if self.listing is not None: + qs.filter(spectacle__listing=self.listing) + return qs -class AttributionInlineNoListing(admin.TabularInline): - model = Attribution +class WithListingAttributionInline(AttributionInline): + form = WithListingAttributionTabularAdminForm + listing = True + + +class WithoutListingAttributionInline(AttributionInline): exclude = ('given', ) - extra = 0 - form = AttributionNoListingAdminForm - - def get_queryset(self, request): - qs = super().get_queryset(request) - return qs.filter(spectacle__listing=False) + form = WithoutListingAttributionTabularAdminForm + listing = False class ParticipantAdminForm(forms.ModelForm): @@ -91,7 +82,7 @@ class ParticipantAdminForm(forms.ModelForm): class ParticipantAdmin(ReadOnlyMixin, admin.ModelAdmin): - inlines = [AttributionInlineListing, AttributionInlineNoListing] + inlines = [WithListingAttributionInline, WithoutListingAttributionInline] def get_queryset(self, request): return Participant.objects.annotate(nb_places=Count('attributions'), From b0e7ebfbc5ba73f0ea233439e9d0038d552d239f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Wed, 10 May 2017 12:49:14 +0200 Subject: [PATCH 58/61] fix typo + pep8 + del future imports --- kfet/backends.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/kfet/backends.py b/kfet/backends.py index c0aec699..fb9538d0 100644 --- a/kfet/backends.py +++ b/kfet/backends.py @@ -1,15 +1,12 @@ # -*- coding: utf-8 -*- -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * - import hashlib from django.contrib.auth.models import User, Permission from gestioncof.models import CofProfile from kfet.models import Account, GenericTeamToken + class KFetBackend(object): def authenticate(self, request): password = request.POST.get('KFETPASSWORD', '') @@ -18,13 +15,15 @@ class KFetBackend(object): return None try: - password_sha256 = hashlib.sha256(password.encode('utf-8')).hexdigest() + password_sha256 = ( + hashlib.sha256(password.encode('utf-8')) + .hexdigest() + ) account = Account.objects.get(password=password_sha256) - user = account.cofprofile.user + return account.cofprofile.user except Account.DoesNotExist: return None - return user class GenericTeamBackend(object): def authenticate(self, username=None, token=None): @@ -48,7 +47,7 @@ class GenericTeamBackend(object): try: return ( User.objects - .select_related('profile__acount_kfet') + .select_related('profile__account_kfet') .get(pk=user_id) ) except User.DoesNotExist: From b1e46792c8958c153b81248532d43697bafdace5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Wed, 10 May 2017 13:11:47 +0200 Subject: [PATCH 59/61] (little) cleaning of order_create view --- kfet/views.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/kfet/views.py b/kfet/views.py index c522bb85..7f06b80e 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1823,23 +1823,17 @@ def order_create(request, pk): ) 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} - ) + sales = [ + {d['article']: d['nb'] for d in chunk} + for chunk in chunks + ] initial = [] for article in articles: # 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] + v_all = [chunk.get(article.pk, 0) for chunk in sales] # Take the 3 greatest (eg to avoid 2 weeks of vacations) v_3max = heapq.nlargest(3, v_all) # Get average and standard deviation @@ -1869,11 +1863,7 @@ def order_create(request, pk): 'category__name': article.category.name, 'stock': article.stock, 'box_capacity': article.box_capacity, - 'v_s1': v_s1, - 'v_s2': v_s2, - 'v_s3': v_s3, - 'v_s4': v_s4, - 'v_s5': v_s5, + 'v_all': v_all, 'v_moy': round(v_moy), 'v_et': round(v_et), 'v_prev': round(v_prev), From 4ac7b30bdd351ed4e4670e7288746d4e710518f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 12 May 2017 16:55:18 +0200 Subject: [PATCH 60/61] Fix UserGroupForm + tests for this form. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Non-K-Fêt group membership is no longer erased by the account edit form. - Add some tests to ensure proposed choices in this form corresponds to K-Fêt groups + test case for #161. Fixes #161 --- kfet/forms.py | 13 ++++++----- kfet/tests/test_forms.py | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 kfet/tests/test_forms.py diff --git a/kfet/forms.py b/kfet/forms.py index f89b8f08..826df257 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -2,6 +2,7 @@ from datetime import timedelta from decimal import Decimal +from itertools import chain from django import forms from django.core.exceptions import ValidationError @@ -134,6 +135,7 @@ class UserRestrictTeamForm(UserForm): class Meta(UserForm.Meta): fields = ['first_name', 'last_name', 'email'] + class UserGroupForm(forms.ModelForm): groups = forms.ModelMultipleChoiceField( Group.objects.filter(name__icontains='K-Fêt'), @@ -141,16 +143,15 @@ class UserGroupForm(forms.ModelForm): required=False) def clean_groups(self): - groups = self.cleaned_data.get('groups') - # Si aucun groupe, on le dénomme - if not groups: - groups = self.instance.groups.exclude(name__icontains='K-Fêt') - return groups + kfet_groups = self.cleaned_data.get('groups') + other_groups = self.instance.groups.exclude(name__icontains='K-Fêt') + return chain(kfet_groups, other_groups) class Meta: - model = User + model = User fields = ['groups'] + class GroupForm(forms.ModelForm): permissions = forms.ModelMultipleChoiceField( queryset= Permission.objects.filter(content_type__in= diff --git a/kfet/tests/test_forms.py b/kfet/tests/test_forms.py new file mode 100644 index 00000000..27c7b3d8 --- /dev/null +++ b/kfet/tests/test_forms.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +from django.test import TestCase +from django.contrib.auth.models import User, Group + +from kfet.forms import UserGroupForm + + +class UserGroupFormTests(TestCase): + """Test suite for UserGroupForm.""" + + def setUp(self): + # create user + self.user = User.objects.create(username="foo", password="foo") + + # create some K-Fêt groups + prefix_name = "K-Fêt " + names = ["Group 1", "Group 2", "Group 3"] + self.kfet_groups = [ + Group.objects.create(name=prefix_name+name) + for name in names + ] + + # create a non-K-Fêt group + self.other_group = Group.objects.create(name="Other group") + + def test_choices(self): + """Only K-Fêt groups are selectable.""" + form = UserGroupForm(instance=self.user) + groups_field = form.fields['groups'] + self.assertEqual(len(groups_field.choices), len(self.kfet_groups)) + + def test_keep_others(self): + """User stays in its non-K-Fêt groups.""" + user = self.user + + # add user to a non-K-Fêt group + user.groups.add(self.other_group) + + # add user to some K-Fêt groups through UserGroupForm + data = { + 'groups': [group.pk for group in self.kfet_groups], + } + form = UserGroupForm(data, instance=user) + + form.is_valid() + form.save() + self.assertEqual(len(user.groups.all()), 1+len(self.kfet_groups)) From e0b1db1e1e14341c512b4bddbe29a871004ac977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 14 May 2017 22:19:25 +0200 Subject: [PATCH 61/61] more robust tests --- kfet/forms.py | 3 +-- kfet/tests/test_forms.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/kfet/forms.py b/kfet/forms.py index 826df257..96787ddd 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -2,7 +2,6 @@ from datetime import timedelta from decimal import Decimal -from itertools import chain from django import forms from django.core.exceptions import ValidationError @@ -145,7 +144,7 @@ class UserGroupForm(forms.ModelForm): def clean_groups(self): kfet_groups = self.cleaned_data.get('groups') other_groups = self.instance.groups.exclude(name__icontains='K-Fêt') - return chain(kfet_groups, other_groups) + return list(kfet_groups) + list(other_groups) class Meta: model = User diff --git a/kfet/tests/test_forms.py b/kfet/tests/test_forms.py index 27c7b3d8..7f129a3f 100644 --- a/kfet/tests/test_forms.py +++ b/kfet/tests/test_forms.py @@ -28,7 +28,11 @@ class UserGroupFormTests(TestCase): """Only K-Fêt groups are selectable.""" form = UserGroupForm(instance=self.user) groups_field = form.fields['groups'] - self.assertEqual(len(groups_field.choices), len(self.kfet_groups)) + self.assertQuerysetEqual( + groups_field.queryset, + [repr(g) for g in self.kfet_groups], + ordered=False, + ) def test_keep_others(self): """User stays in its non-K-Fêt groups.""" @@ -45,4 +49,8 @@ class UserGroupFormTests(TestCase): form.is_valid() form.save() - self.assertEqual(len(user.groups.all()), 1+len(self.kfet_groups)) + self.assertQuerysetEqual( + user.groups.all(), + [repr(g) for g in [self.other_group] + self.kfet_groups], + ordered=False, + )