diff --git a/bda/models.py b/bda/models.py index 15acf686..0228b4c0 100644 --- a/bda/models.py +++ b/bda/models.py @@ -18,7 +18,10 @@ class Tirage(models.Model): fermeture = models.DateTimeField("Date et heure de fermerture du tirage") tokens = models.TextField("Graine(s) du tirage", blank=True) active = models.BooleanField("Tirage actif", default=False) - appear_catalogue = models.BooleanField("Tirage à afficher dans le catalogue", default=False) + appear_catalogue = models.BooleanField( + "Tirage à afficher dans le catalogue", + default=False + ) enable_do_tirage = models.BooleanField("Le tirage peut être lancé", default=False) diff --git a/bda/tests.py b/bda/tests.py index 22efc5a2..2741084f 100644 --- a/bda/tests.py +++ b/bda/tests.py @@ -1,22 +1,79 @@ -# -*- coding: utf-8 -*- -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". +import json -Replace this with more appropriate tests for your application. -""" +from django.test import TestCase, Client +from django.utils import timezone -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals +from .models import Tirage, Spectacle, Salle, CategorieSpectacle -from django.test import TestCase +class TestBdAViews(TestCase): + def setUp(self): + self.tirage = Tirage.objects.create( + title="Test tirage", + appear_catalogue=True, + ouverture=timezone.now(), + fermeture=timezone.now(), + ) + self.category = CategorieSpectacle.objects.create(name="Category") + self.location = Salle.objects.create(name="here") + Spectacle.objects.bulk_create([ + Spectacle( + title="foo", date=timezone.now(), location=self.location, + price=0, slots=42, tirage=self.tirage, listing=False, + category=self.category + ), + Spectacle( + title="bar", date=timezone.now(), location=self.location, + price=1, slots=142, tirage=self.tirage, listing=False, + category=self.category + ), + Spectacle( + title="baz", date=timezone.now(), location=self.location, + price=2, slots=242, tirage=self.tirage, listing=False, + category=self.category + ), + ]) + def test_catalogue(self): + """Test the catalogue JSON API""" + client = Client() -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) + # The `list` hooh + resp = client.get("/bda/catalogue/list") + self.assertJSONEqual( + resp.content.decode("utf-8"), + [{"id": self.tirage.id, "title": self.tirage.title}] + ) + + # The `details` hook + resp = client.get( + "/bda/catalogue/details?id={}".format(self.tirage.id) + ) + self.assertJSONEqual( + resp.content.decode("utf-8"), + { + "categories": [{ + "id": self.category.id, + "name": self.category.name + }], + "locations": [{ + "id": self.location.id, + "name": self.location.name + }], + } + ) + + # The `descriptions` hook + resp = client.get( + "/bda/catalogue/descriptions?id={}".format(self.tirage.id) + ) + raw = resp.content.decode("utf-8") + try: + results = json.loads(raw) + except ValueError: + self.fail("Not valid JSON: {}".format(raw)) + self.assertEqual(len(results), 3) + self.assertEqual( + {(s["title"], s["price"], s["slots"]) for s in results}, + {("foo", 0, 42), ("bar", 1, 142), ("baz", 2, 242)} + ) diff --git a/bda/views.py b/bda/views.py index 41e5d08b..8fda604d 100644 --- a/bda/views.py +++ b/bda/views.py @@ -22,7 +22,6 @@ from django.core.urlresolvers import reverse from django.conf import settings from django.utils import timezone, formats from django.views.generic.list import ListView -from django.core.exceptions import ObjectDoesNotExist from gestioncof.decorators import cof_required, buro_required from bda.models import ( Spectacle, Participant, ChoixSpectacle, Attribution, Tirage, @@ -657,29 +656,35 @@ def catalogue(request, request_type): if request_type == "list": # Dans ce cas on retourne la liste des tirages et de leur id en JSON data_return = list( - Tirage.objects.filter(appear_catalogue=True).values('id', 'title')) + Tirage.objects.filter(appear_catalogue=True).values('id', 'title') + ) return JsonResponse(data_return, safe=False) if request_type == "details": # Dans ce cas on retourne une liste des catégories et des salles - tirage_id = request.GET.get('id', '') - try: - tirage = Tirage.objects.get(id=tirage_id) - except ObjectDoesNotExist: + tirage_id = request.GET.get('id', None) + if tirage_id is None: return HttpResponseBadRequest( - "Aucun tirage correspondant à l'id " - + tirage_id) + "Missing GET parameter: id " + ) + try: + tirage = get_object_or_404(Tirage, id=int(tirage_id)) except ValueError: return HttpResponseBadRequest( - "Mauvais format d'identifiant : " - + tirage_id) + "Bad format: int expected for `id`" + ) + shows = tirage.spectacle_set.values_list("id", flat=True) categories = list( - CategorieSpectacle.objects.filter( - spectacle__in=tirage.spectacle_set.all()) - .distinct().values('id', 'name')) + CategorieSpectacle.objects + .filter(spectacle__in=shows) + .distinct() + .values('id', 'name') + ) locations = list( - Salle.objects.filter( - spectacle__in=tirage.spectacle_set.all()) - .distinct().values('id', 'name')) + Salle.objects + .filter(spectacle__in=shows) + .distinct() + .values('id', 'name') + ) data_return = {'categories': categories, 'locations': locations} return JsonResponse(data_return, safe=False) if request_type == "descriptions": @@ -687,33 +692,35 @@ def catalogue(request, request_type): # à la salle spécifiées tirage_id = request.GET.get('id', '') - categories = request.GET.get('category', '[0]') - locations = request.GET.get('location', '[0]') + categories = request.GET.get('category', '[]') + locations = request.GET.get('location', '[]') try: - category_id = json.loads(categories) - location_id = json.loads(locations) - tirage = Tirage.objects.get(id=tirage_id) - - shows_qs = tirage.spectacle_set - if not(0 in category_id): - shows_qs = shows_qs.filter( - category__id__in=category_id) - if not(0 in location_id): - shows_qs = shows_qs.filter( - location__id__in=location_id) - except ObjectDoesNotExist: - return HttpResponseBadRequest( - "Impossible de trouver des résultats correspondant " - "à ces caractéristiques : " - + "id = " + tirage_id - + ", catégories = " + categories - + ", salles = " + locations) + tirage_id = int(tirage_id) + categories_id = json.loads(categories) + locations_id = json.loads(locations) + # Integers expected + if not all(isinstance(id, int) for id in categories_id): + raise ValueError + if not all(isinstance(id, int) for id in locations_id): + raise ValueError except ValueError: # Contient JSONDecodeError return HttpResponseBadRequest( - "Impossible de parser les paramètres donnés : " - + "id = " + request.GET.get('id', '') - + ", catégories = " + request.GET.get('category', '[0]') - + ", salles = " + request.GET.get('location', '[0]')) + "Parse error, please ensure the GET parameters have the " + "following types:\n" + "id: int, category: [int], location: [int]\n" + "Data received:\n" + "id = {}, category = {}, locations = {}" + .format(request.GET.get('id', ''), + request.GET.get('category', '[]'), + request.GET.get('location', '[]')) + ) + tirage = get_object_or_404(Tirage, id=tirage_id) + + shows_qs = tirage.spectacle_set + if categories_id: + shows_qs = shows_qs.filter(category__id__in=categories_id) + if locations_id: + shows_qs = shows_qs.filter(location__id__in=locations_id) # On convertit les descriptions à envoyer en une liste facilement # JSONifiable (il devrait y avoir un moyen plus efficace en diff --git a/cof/settings_dev.py b/cof/settings_dev.py index 750a53ee..9e10d733 100644 --- a/cof/settings_dev.py +++ b/cof/settings_dev.py @@ -48,6 +48,7 @@ INSTALLED_APPS = ( 'widget_tweaks', 'django_js_reverse', 'custommail', + 'djconfig', ) MIDDLEWARE_CLASSES = ( @@ -61,6 +62,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', + 'djconfig.middleware.DjConfigMiddleware', ) ROOT_URLCONF = 'cof.urls' @@ -79,8 +81,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..76da5a79 --- /dev/null +++ b/kfet/config.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +from django.core.exceptions import ValidationError +from django.db import models + +from djconfig import config + + +class KFetConfig(object): + """kfet app configuration. + + Enhance isolation with backend used to store config. + Usable after DjConfig middleware was called. + + """ + 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): + """Get list of kfet app configuration. + + Returns: + (key, value) for each configuration entry as list. + + """ + # prevent circular imports + from kfet.forms import KFetConfigForm + return [(field.label, getattr(config, name), ) + for name, field in KFetConfigForm.base_fields.items()] + + def _get_dj_key(self, key): + return '{}{}'.format(self.prefix, key) + + def set(self, **kwargs): + """Update configuration value(s). + + Args: + **kwargs: Keyword arguments. Keys must be in kfet config. + Config entries are updated to given values. + + """ + # prevent circular imports + from kfet.forms import KFetConfigForm + + # get old config + new_cfg = KFetConfigForm().initial + # update to new config + for key, value in kwargs.items(): + dj_key = self._get_dj_key(key) + if isinstance(value, models.Model): + new_cfg[dj_key] = value.pk + else: + new_cfg[dj_key] = value + # save new config + cfg_form = KFetConfigForm(new_cfg) + if cfg_form.is_valid(): + cfg_form.save() + else: + raise ValidationError( + 'Invalid values in kfet_config.set: %(fields)s', + params={'fields': list(cfg_form.errors)}) + + +kfet_config = KFetConfig() diff --git a/kfet/consumers.py b/kfet/consumers.py index dcd69bdf..ee096368 100644 --- a/kfet/consumers.py +++ b/kfet/consumers.py @@ -1,26 +1,41 @@ # -*- 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. + + """ + + @classmethod + def encode_json(cls, content): + return json.dumps(content, cls=DjangoJSONEncoder) + + +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): - pass + """Check permissions on connection.""" + if message.user.has_perms(self.perms_connect): + super().connect(message, **kwargs) + else: + self.close() - def receive(self, content, **kwargs): - pass - def disconnect(self, message, **kwargs): - pass +class KPsul(PermConsumerMixin, DjangoJsonWebsocketConsumer): + groups = ['kfet.kpsul'] + perms_connect = ['kfet.is_team'] 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 7acd0880..f89b8f08 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 # ----- @@ -389,40 +395,46 @@ 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_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 (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.', + 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 (en €)', + 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/0053_created_at.py b/kfet/migrations/0053_created_at.py new file mode 100644 index 00000000..a868de33 --- /dev/null +++ b/kfet/migrations/0053_created_at.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0052_category_addcost'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/kfet/migrations/0054_delete_settings.py b/kfet/migrations/0054_delete_settings.py new file mode 100644 index 00000000..80ee1d24 --- /dev/null +++ b/kfet/migrations/0054_delete_settings.py @@ -0,0 +1,58 @@ +# -*- 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: + 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') + 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', '0053_created_at'), + ('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 cb8c324b..6c1f1240 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) @@ -41,7 +37,7 @@ class Account(models.Model): max_digits = 6, decimal_places = 2, default = 0) is_frozen = models.BooleanField("est gelé", default = False) - created_at = models.DateTimeField(auto_now_add = True, null = True) + created_at = models.DateTimeField(default=timezone.now) # Optional PROMO_CHOICES = [(r,r) for r in range(1980, date.today().year+1)] promo = models.IntegerField( @@ -85,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 @@ -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 @@ -214,6 +210,29 @@ class Account(models.Model): def delete(self, *args, **kwargs): pass + def update_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 @@ -632,116 +651,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/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index f21fdaba..0244a57b 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -86,6 +86,16 @@ textarea { color:#FFF; } +.buttons .nav-pills > li > a { + border-radius:0; + border:1px solid rgba(200,16,46,0.9); +} + +.buttons .nav-pills > li.active > a { + background-color:rgba(200,16,46,0.9); + background-clip:padding-box; +} + .row-page-header { background-color:rgba(200,16,46,1); color:#FFF; 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 7fd2d9c7..3183fdc4 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -23,11 +23,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") ); }); @@ -61,45 +61,40 @@ $(document).ready(function() {
{% include "kfet/base_messages.html" %}
- {% if addcosts %} -
-

Gagné des majorations

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

Statistiques

-
-
-
-

Ma balance

-
-
-
-
-
-
-
-

Ma consommation

-
-
-
-
-
- {% endif %}
-

Historique

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

Statistiques

+
+

Ma balance

+
+

Ma consommation

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

Gagné des majorations

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

Historique

+
+ {% if account.user == request.user %} +
+
+ {% endif %} +
+
+ diff --git a/kfet/templates/kfet/home.html b/kfet/templates/kfet/home.html index 96d28755..687c8573 100644 --- a/kfet/templates/kfet/home.html +++ b/kfet/templates/kfet/home.html @@ -20,40 +20,42 @@

Carte

-
-
-
-

Pressions du moment

-
    - {% for article in pressions %} -
  • -
    - {{ article.name }} - {{ article.price | ukf:False}} UKF -
  • - {% endfor %} -
-
- {% for article in articles %} - {% ifchanged article.category %} - {% if not forloop.first %} - -
- {% endif %} -
-

{{ article.category.name }}

-
    - {% endifchanged %} -
  • -
    - {{ article.name }} - {{ article.price | ukf:False}} UKF -
  • - {% if foorloop.last %} -
-
- {% endif %} +
+
+
+ {% if pressions %} +

Pressions du moment

+
    + {% for article in pressions %} +
  • +
    + {{ article.name }} + {{ article.price | ukf:False}} UKF +
  • {% endfor %} +
+ {% endif %} +
+ {% for article in articles %} + {% ifchanged article.category %} + {% if not forloop.first %} + +
+ {% endif %} +
+

{{ article.category.name }}

+
    + {% endifchanged %} +
  • +
    + {{ article.name }} + {{ article.price | ukf:False}} UKF +
  • + {% if forloop.last %} +
+
+ {% endif %} + {% endfor %}
diff --git a/kfet/templates/kfet/kpsul.html b/kfet/templates/kfet/kpsul.html index c1e5fec6..36d4c3af 100644 --- a/kfet/templates/kfet/kpsul.html +++ b/kfet/templates/kfet/kpsul.html @@ -246,7 +246,7 @@ $(document).ready(function() { var reduc_divisor = 1; if (kpsul.account_manager.account.is_cof) reduc_divisor = 1 + Config.get('subvention_cof') / 100; - return amount_euro / reduc_divisor; + return (amount_euro / reduc_divisor).toFixed(2); } function addPurchase(article, nb) { @@ -261,7 +261,7 @@ $(document).ready(function() { } }); if (!existing) { - var amount_euro = amountEuroPurchase(article, nb).toFixed(2); + var amount_euro = amountEuroPurchase(article, nb); var index = addPurchaseToFormset(article.id, nb, amount_euro); var article_basket_html = $(item_basket_default_html); article_basket_html diff --git a/kfet/templates/kfet/left_account.html b/kfet/templates/kfet/left_account.html index 19352728..5607cbc2 100644 --- a/kfet/templates/kfet/left_account.html +++ b/kfet/templates/kfet/left_account.html @@ -36,6 +36,12 @@
+ {% if account.user == request.user %} + + {% endif %} Modifier diff --git a/kfet/templates/kfet/settings.html b/kfet/templates/kfet/settings.html index bf48f3bb..96b1b6e7 100644 --- a/kfet/templates/kfet/settings.html +++ b/kfet/templates/kfet/settings.html @@ -5,20 +5,42 @@ {% block content %} -{% include 'kfet/base_messages.html' %} - - - - - - - {% for setting in settings %} - - - - - - {% endfor %} -
NomValeur
Modifier{{ setting.name }}{% firstof setting.value_decimal setting.value_duration setting.value_account %}
+
+
+ +
+
+ {% include 'kfet/base_messages.html' %} +
+
+

Valeurs

+
+ + + + + + + + + {% for key, value in kfet_config.list %} + + + + + {% endfor %} + +
NomValeur
{{ key }}{{ value }}
+
+
+
+
+
{% endblock %} diff --git a/kfet/templates/kfet/settings_update.html b/kfet/templates/kfet/settings_update.html index 3d0596c2..fdd5f5d4 100644 --- a/kfet/templates/kfet/settings_update.html +++ b/kfet/templates/kfet/settings_update.html @@ -1,14 +1,25 @@ {% extends 'kfet/base.html' %} -{% block title %}Modification de {{ settings.name }}{% endblock %} -{% block content-header-title %}Modification de {{ settings.name }}{% endblock %} +{% block title %}Modification des paramètres{% endblock %} +{% block content-header-title %}Modification des paramètres{% endblock %} {% block content %} -
- {% csrf_token %} - {{ form.as_p }} - -
+{% include "kfet/base_messages.html" %} + +
+
+
+
+ {% csrf_token %} + {% include 'kfet/form_snippet.html' with form=form %} + {% if not perms.kfet.change_settings %} + {% include 'kfet/form_authentication_snippet.html' %} + {% endif %} + {% include 'kfet/form_submit_snippet.html' with value="Mettre à jour" %} +
+
+
+
{% endblock %} diff --git a/kfet/templatetags/kfet_tags.py b/kfet/templatetags/kfet_tags.py index e21bb778..7fa9d7c7 100644 --- a/kfet/templatetags/kfet_tags.py +++ b/kfet/templatetags/kfet_tags.py @@ -3,10 +3,11 @@ from django import template from django.utils.html import escape from django.utils.safestring import mark_safe -from kfet.models import Settings from math import floor import re +from kfet.config import kfet_config + register = template.Library() @@ -40,5 +41,5 @@ def highlight_clipper(clipper, q): @register.filter() def ukf(balance, is_cof): - grant = is_cof and (1 + Settings.SUBVENTION_COF() / 100) or 1 + grant = is_cof and (1 + kfet_config.subvention_cof / 100) or 1 return floor(balance * 10 * grant) diff --git a/kfet/tests/__init__.py b/kfet/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kfet/tests/test_config.py b/kfet/tests/test_config.py new file mode 100644 index 00000000..03c9cf3c --- /dev/null +++ b/kfet/tests/test_config.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +from decimal import Decimal + +from django.test import TestCase +from django.utils import timezone + +import djconfig + +from gestioncof.models import User +from kfet.config import kfet_config +from kfet.models import Account + + +class ConfigTest(TestCase): + """Tests suite for kfet configuration.""" + + def setUp(self): + # load configuration as in djconfig middleware + djconfig.reload_maybe() + + 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.""" + reduction_cof = Decimal('10') + # IUT + kfet_config.set(reduction_cof=reduction_cof) + # check + self.assertEqual(kfet_config.reduction_cof, reduction_cof) + + def test_set_modelinstance(self): + """Test field of model instance type.""" + user = User.objects.create(username='foo_user') + account = Account.objects.create(trigramme='FOO', + cofprofile=user.profile) + # IUT + kfet_config.set(addcost_for=account) + # check + self.assertEqual(kfet_config.addcost_for, account) + + def test_set_duration(self): + """Test field of duration type.""" + cancel_duration = timezone.timedelta(days=2, hours=4) + # IUT + kfet_config.set(cancel_duration=cancel_duration) + # check + self.assertEqual(kfet_config.cancel_duration, cancel_duration) 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): diff --git a/kfet/urls.py b/kfet/urls.py index 46dc7203..b3bd1f48 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -188,7 +188,7 @@ urlpatterns = [ permission_required('kfet.change_settings') (views.SettingsList.as_view()), name='kfet.settings'), - url(r'^settings/(?P\d+)/edit$', + url(r'^settings/edit$', permission_required('kfet.change_settings') (views.SettingsUpdate.as_view()), name='kfet.settings.update'), diff --git a/kfet/views.py b/kfet/views.py index 16fdc44c..e0fc6e35 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -6,7 +6,7 @@ from urllib.parse import urlencode from django.shortcuts import render, get_object_or_404, redirect from django.core.exceptions import PermissionDenied from django.core.cache import cache -from django.views.generic import ListView, DetailView, TemplateView +from django.views.generic import ListView, DetailView, TemplateView, FormView from django.views.generic.detail import BaseDetailView from django.views.generic.edit import CreateView, UpdateView from django.core.urlresolvers import reverse, reverse_lazy @@ -24,9 +24,11 @@ from django.utils import timezone from django.utils.crypto import get_random_string from django.utils.decorators import method_decorator from gestioncof.models import CofProfile + +from kfet.config import kfet_config from kfet.decorators import teamkfet_required from kfet.models import ( - Account, Checkout, Article, Settings, AccountNegative, + Account, Checkout, Article, AccountNegative, CheckoutStatement, GenericTeamToken, Supplier, SupplierArticle, Inventory, InventoryArticle, Order, OrderArticle, Operation, OperationGroup, TransferGroup, Transfer, ArticleCategory) @@ -37,9 +39,9 @@ from kfet.forms import ( GroupForm, CheckoutForm, CheckoutRestrictForm, CheckoutStatementCreateForm, CheckoutStatementUpdateForm, ArticleForm, ArticleRestrictForm, KPsulOperationGroupForm, KPsulAccountForm, KPsulCheckoutForm, - KPsulOperationFormSet, AddcostForm, FilterHistoryForm, SettingsForm, + KPsulOperationFormSet, AddcostForm, FilterHistoryForm, TransferFormSet, InventoryArticleForm, OrderArticleForm, - OrderArticleToInventoryForm, CategoryForm + OrderArticleToInventoryForm, CategoryForm, KFetConfigForm ) from collections import defaultdict from kfet import consumers @@ -378,8 +380,8 @@ def account_create_ajax(request, username=None, login_clipper=None, @login_required def account_read(request, trigramme): try: - account = Account.objects.select_related('negative')\ - .get(trigramme=trigramme) + account = (Account.objects.select_related('negative') + .get(trigramme=trigramme)) except Account.DoesNotExist: raise Http404 @@ -392,7 +394,6 @@ def account_read(request, trigramme): export_keys = ['id', 'trigramme', 'first_name', 'last_name', 'name', 'email', 'is_cof', 'promo', 'balance', 'is_frozen', 'departement', 'nickname'] - print(account.first_name) data = {k: getattr(account, k) for k in export_keys} return JsonResponse(data) @@ -408,7 +409,6 @@ def account_read(request, trigramme): return render(request, "kfet/account_read.html", { 'account': account, 'addcosts': addcosts, - 'settings': {'subvention_cof': Settings.SUBVENTION_COF()}, }) @@ -587,10 +587,6 @@ class AccountNegativeList(ListView): def get_context_data(self, **kwargs): context = super(AccountNegativeList, self).get_context_data(**kwargs) - context['settings'] = { - 'overdraft_amount': Settings.OVERDRAFT_AMOUNT(), - 'overdraft_duration': Settings.OVERDRAFT_DURATION(), - } negs_sum = (AccountNegative.objects .exclude(account__trigramme='#13') .aggregate( @@ -961,13 +957,14 @@ def kpsul(request): data['operation_formset'] = operation_formset return render(request, 'kfet/kpsul.html', data) + @teamkfet_required def kpsul_get_settings(request): - addcost_for = Settings.ADDCOST_FOR() + addcost_for = kfet_config.addcost_for data = { - 'subvention_cof': Settings.SUBVENTION_COF(), - 'addcost_for' : addcost_for and addcost_for.trigramme or '', - 'addcost_amount': Settings.ADDCOST_AMOUNT(), + 'subvention_cof': kfet_config.subvention_cof, + 'addcost_for': addcost_for and addcost_for.trigramme or '', + 'addcost_amount': kfet_config.addcost_amount, } return JsonResponse(data) @@ -991,15 +988,15 @@ def kpsul_update_addcost(request): trigramme = addcost_form.cleaned_data['trigramme'] account = trigramme and Account.objects.get(trigramme=trigramme) or None - Settings.objects.filter(name='ADDCOST_FOR').update(value_account=account) - (Settings.objects.filter(name='ADDCOST_AMOUNT') - .update(value_decimal=addcost_form.cleaned_data['amount'])) - cache.delete('ADDCOST_FOR') - cache.delete('ADDCOST_AMOUNT') + amount = addcost_form.cleaned_data['amount'] + + kfet_config.set(addcost_for=account, + addcost_amount=amount) + data = { 'addcost': { - 'for': trigramme and account.trigramme or None, - 'amount': addcost_form.cleaned_data['amount'], + 'for': account and account.trigramme or None, + 'amount': amount, } } consumers.KPsul.group_send('kfet.kpsul', data) @@ -1043,10 +1040,10 @@ def kpsul_perform_operations(request): operations = operation_formset.save(commit=False) # Retrieving COF grant - cof_grant = Settings.SUBVENTION_COF() + cof_grant = kfet_config.subvention_cof # Retrieving addcosts data - addcost_amount = Settings.ADDCOST_AMOUNT() - addcost_for = Settings.ADDCOST_FOR() + addcost_amount = kfet_config.addcost_amount + addcost_for = kfet_config.addcost_for # Initializing vars required_perms = set() # Required perms to perform all operations @@ -1122,22 +1119,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.update_negative() # Updating checkout's balance if to_checkout_balance: @@ -1272,8 +1262,9 @@ def kpsul_cancel_operations(request): opes = [] # Pas déjà annulée transfers = [] required_perms = set() + stop_all = False - cancel_duration = Settings.CANCEL_DURATION() + cancel_duration = kfet_config.cancel_duration # Modifs à faire sur les balances des comptes to_accounts_balances = defaultdict(lambda: 0) # ------ sur les montants des groupes d'opé @@ -1282,6 +1273,7 @@ def kpsul_cancel_operations(request): to_checkouts_balances = defaultdict(lambda: 0) # ------ sur les stocks d'articles to_articles_stocks = defaultdict(lambda: 0) + for ope in opes_all: if ope.canceled_at: # Opération déjà annulée, va pour un warning en Response @@ -1391,8 +1383,15 @@ def kpsul_cancel_operations(request): .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.update_negative() for checkout in to_checkouts_balances: Checkout.objects.filter(pk=checkout.pk).update( balance=F('balance') + to_checkouts_balances[checkout]) @@ -1651,34 +1650,28 @@ def kpsul_articles_data(request): return JsonResponse(data) + @teamkfet_required def history(request): data = { 'filter_form': FilterHistoryForm(), - 'settings': { - 'subvention_cof': Settings.SUBVENTION_COF(), - } } return render(request, 'kfet/history.html', data) + # ----- # Settings views # ----- -class SettingsList(ListView): - model = Settings - context_object_name = 'settings' + +class SettingsList(TemplateView): template_name = 'kfet/settings.html' - def get_context_data(self, **kwargs): - Settings.create_missing() - return super(SettingsList, self).get_context_data(**kwargs) -class SettingsUpdate(SuccessMessageMixin, UpdateView): - model = Settings - form_class = SettingsForm +class SettingsUpdate(SuccessMessageMixin, FormView): + form_class = KFetConfigForm template_name = 'kfet/settings_update.html' - success_message = 'Paramètre %(name)s mis à jour' + success_message = 'Paramètres mis à jour' success_url = reverse_lazy('kfet.settings') def form_valid(self, form): @@ -1686,9 +1679,9 @@ class SettingsUpdate(SuccessMessageMixin, UpdateView): if not self.request.user.has_perm('kfet.change_settings'): form.add_error(None, 'Permission refusée') return self.form_invalid(form) - # Creating - Settings.empty_cache() - return super(SettingsUpdate, self).form_valid(form) + form.save() + return super().form_valid(form) + # ----- # Transfer views diff --git a/requirements.txt b/requirements.txt index 0a59ed06..a0adbe1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ Django==1.8.* django-autocomplete-light==2.3.3 django-autoslug==1.9.3 django-cas-ng==3.5.5 +django-djconfig==0.5.3 django-grappelli==2.8.1 django-recaptcha==1.0.5 mysqlclient==1.3.7 @@ -11,14 +12,14 @@ 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 django-js-reverse==0.7.3 python-dateutil