diff --git a/bda/admin.py b/bda/admin.py index fc10c326..0cc66d43 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -13,32 +13,76 @@ from bda.models import Spectacle, Salle, Participant, ChoixSpectacle,\ Attribution, Tirage, Quote, CategorieSpectacle, SpectacleRevente +class ReadOnlyMixin(object): + readonly_fields_update = () + + def get_readonly_fields(self, request, obj=None): + readonly_fields = super().get_readonly_fields(request, obj) + if obj is None: + return readonly_fields + else: + return readonly_fields + self.readonly_fields_update + + class ChoixSpectacleInline(admin.TabularInline): model = ChoixSpectacle sortable_field_name = "priority" +class AttributionTabularAdminForm(forms.ModelForm): + listing = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + spectacles = Spectacle.objects.select_related('location') + if self.listing is not None: + spectacles = spectacles.filter(listing=self.listing) + self.fields['spectacle'].queryset = spectacles + + +class WithoutListingAttributionTabularAdminForm(AttributionTabularAdminForm): + listing = False + + +class WithListingAttributionTabularAdminForm(AttributionTabularAdminForm): + listing = True + + class AttributionInline(admin.TabularInline): model = Attribution extra = 0 + listing = None def get_queryset(self, request): - qs = super(AttributionInline, self).get_queryset(request) - return qs.filter(spectacle__listing=False) + qs = super().get_queryset(request) + if self.listing is not None: + qs.filter(spectacle__listing=self.listing) + return qs -class AttributionInlineListing(admin.TabularInline): - model = Attribution +class WithListingAttributionInline(AttributionInline): + form = WithListingAttributionTabularAdminForm + listing = True + + +class WithoutListingAttributionInline(AttributionInline): exclude = ('given', ) - extra = 0 - - def get_queryset(self, request): - qs = super(AttributionInlineListing, self).get_queryset(request) - return qs.filter(spectacle__listing=True) + form = WithoutListingAttributionTabularAdminForm + listing = False -class ParticipantAdmin(admin.ModelAdmin): - inlines = [AttributionInline, AttributionInlineListing] +class ParticipantAdminForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['choicesrevente'].queryset = ( + Spectacle.objects + .select_related('location') + ) + + +class ParticipantAdmin(ReadOnlyMixin, admin.ModelAdmin): + inlines = [WithListingAttributionInline, WithoutListingAttributionInline] def get_queryset(self, request): return Participant.objects.annotate(nb_places=Count('attributions'), @@ -65,6 +109,8 @@ class ParticipantAdmin(admin.ModelAdmin): actions_on_bottom = True list_per_page = 400 readonly_fields = ("total",) + readonly_fields_update = ('user', 'tirage') + form = ParticipantAdminForm def send_attribs(self, request, queryset): datatuple = [] @@ -94,6 +140,20 @@ class ParticipantAdmin(admin.ModelAdmin): class AttributionAdminForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'spectacle' in self.fields: + self.fields['spectacle'].queryset = ( + Spectacle.objects + .select_related('location') + ) + if 'participant' in self.fields: + self.fields['participant'].queryset = ( + Participant.objects + .select_related('user', 'tirage') + ) + def clean(self): cleaned_data = super(AttributionAdminForm, self).clean() participant = cleaned_data.get("participant") @@ -106,7 +166,7 @@ class AttributionAdminForm(forms.ModelForm): return cleaned_data -class AttributionAdmin(admin.ModelAdmin): +class AttributionAdmin(ReadOnlyMixin, admin.ModelAdmin): def paid(self, obj): return obj.participant.paid paid.short_description = 'A payé' @@ -116,6 +176,7 @@ class AttributionAdmin(admin.ModelAdmin): 'participant__user__first_name', 'participant__user__last_name') form = AttributionAdminForm + readonly_fields_update = ('spectacle', 'participant') class ChoixSpectacleAdmin(admin.ModelAdmin): @@ -160,6 +221,24 @@ class SalleAdmin(admin.ModelAdmin): search_fields = ('name', 'address') +class SpectacleReventeAdminForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['answered_mail'].queryset = ( + Participant.objects + .select_related('user', 'tirage') + ) + self.fields['seller'].queryset = ( + Participant.objects + .select_related('user', 'tirage') + ) + self.fields['soldTo'].queryset = ( + Participant.objects + .select_related('user', 'tirage') + ) + + class SpectacleReventeAdmin(admin.ModelAdmin): """ Administration des reventes de spectacles @@ -182,6 +261,7 @@ class SpectacleReventeAdmin(admin.ModelAdmin): actions = ['transfer', 'reinit'] actions_on_bottom = True + form = SpectacleReventeAdminForm def transfer(self, request, queryset): """ diff --git a/bda/algorithm.py b/bda/algorithm.py index bf9b690f..7f18ce18 100644 --- a/bda/algorithm.py +++ b/bda/algorithm.py @@ -22,8 +22,7 @@ class Algorithm(object): show.requests - on crée des tables de demandes pour chaque personne, afin de pouvoir modifier les rankings""" - self.max_group = \ - 2 * choices.aggregate(Max('priority'))['priority__max'] + self.max_group = 2*max(choice.priority for choice in choices) self.shows = [] showdict = {} for show in shows: diff --git a/bda/forms.py b/bda/forms.py index 2029645b..c0417d1e 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -1,35 +1,40 @@ # -*- 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 BaseBdaFormSet(BaseInlineFormSet): - def clean(self): - """Checks that no two articles have the same title.""" - super(BaseBdaFormSet, self).clean() - if any(self.errors): - # Don't bother validating the formset unless each form is valid on - # its own - return - spectacles = [] - for i in range(0, self.total_form_count()): - form = self.forms[i] - if not form.cleaned_data: - continue - spectacle = form.cleaned_data['spectacle'] - delete = form.cleaned_data['DELETE'] - if not delete and spectacle in spectacles: - raise forms.ValidationError( - "Vous ne pouvez pas vous inscrire deux fois pour le " - "même spectacle.") - spectacles.append(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): @@ -38,7 +43,7 @@ class TokenForm(forms.Form): class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, obj): - return "%s" % obj.spectacle + return "%s" % str(obj.spectacle) class ResellForm(forms.Form): @@ -50,9 +55,13 @@ class ResellForm(forms.Form): def __init__(self, participant, *args, **kwargs): super(ResellForm, self).__init__(*args, **kwargs) - self.fields['attributions'].queryset = participant.attribution_set\ - .filter(spectacle__date__gte=timezone.now())\ + self.fields['attributions'].queryset = ( + participant.attribution_set + .filter(spectacle__date__gte=timezone.now()) .exclude(revente__seller=participant) + .select_related('spectacle', 'spectacle__location', + 'participant__user') + ) class AnnulForm(forms.Form): @@ -64,11 +73,15 @@ class AnnulForm(forms.Form): def __init__(self, participant, *args, **kwargs): super(AnnulForm, self).__init__(*args, **kwargs) - self.fields['attributions'].queryset = participant.attribution_set\ + self.fields['attributions'].queryset = ( + participant.attribution_set .filter(spectacle__date__gte=timezone.now(), revente__isnull=False, revente__notif_sent=False, revente__soldTo__isnull=True) + .select_related('spectacle', 'spectacle__location', + 'participant__user') + ) class InscriptionReventeForm(forms.Form): @@ -79,8 +92,11 @@ class InscriptionReventeForm(forms.Form): def __init__(self, tirage, *args, **kwargs): super(InscriptionReventeForm, self).__init__(*args, **kwargs) - self.fields['spectacles'].queryset = tirage.spectacle_set.filter( - date__gte=timezone.now()) + self.fields['spectacles'].queryset = ( + tirage.spectacle_set + .select_related('location') + .filter(date__gte=timezone.now()) + ) class SoldForm(forms.Form): @@ -93,7 +109,9 @@ class SoldForm(forms.Form): super(SoldForm, self).__init__(*args, **kwargs) self.fields['attributions'].queryset = ( participant.attribution_set - .filter(revente__isnull=False, - revente__soldTo__isnull=False) - .exclude(revente__soldTo=participant) + .filter(revente__isnull=False, + revente__soldTo__isnull=False) + .exclude(revente__soldTo=participant) + .select_related('spectacle', 'spectacle__location', + 'participant__user') ) diff --git a/bda/templates/bda/reventes.html b/bda/templates/bda/reventes.html index e61e7c8d..0912babb 100644 --- a/bda/templates/bda/reventes.html +++ b/bda/templates/bda/reventes.html @@ -4,6 +4,8 @@ {% block realcontent %}

Revente de place

+{% with resell_attributions=resellform.attributions annul_attributions=annulform.attributions sold_attributions=soldform.attributions %} + {% if resellform.attributions %}

Places non revendues

@@ -15,14 +17,14 @@
{% endif %}
-{% if annulform.attributions or overdue %} +{% if annul_attributions or overdue %}

Places en cours de revente

{% csrf_token %}
    - {% for attrib in annulform.attributions %} + {% for attrib in annul_attributions %}
  • {{attrib.tag}} {{attrib.choice_label}}
  • {% endfor %} {% for attrib in overdue %} @@ -31,13 +33,13 @@ {{attrib.spectacle}} {% endfor %} - {% if annulform.attributions %} + {% if annul_attributions %} {% endif %} {% endif %}
    -{% if soldform.attributions %} +{% if sold_attributions %}

    Places revendues

    {% csrf_token %} @@ -46,8 +48,9 @@
    {% endif %} -{% if not resellform.attributions and not soldform.attributions and not overdue and not annulform.attributions %} +{% if not resell_attributions and not annul_attributions and not overdue and not sold_attributions %}

    Plus de reventes possibles !

    {% endif %} +{% endwith %} {% endblock %} diff --git a/bda/views.py b/bda/views.py index 8fda604d..00a1b300 100644 --- a/bda/views.py +++ b/bda/views.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from collections import defaultdict +from functools import partial import random import hashlib import time @@ -11,9 +13,9 @@ from custommail.shortcuts import ( from django.shortcuts import render, get_object_or_404 from django.contrib.auth.decorators import login_required from django.contrib import messages -from django.db import models, transaction +from django.db import transaction from django.core import serializers -from django.db.models import Count, Q, Sum +from django.db.models import Count, Q, Prefetch from django.forms.models import inlineformset_factory from django.http import ( HttpResponseBadRequest, HttpResponseRedirect, JsonResponse @@ -29,8 +31,8 @@ from bda.models import ( ) from bda.algorithm import Algorithm from bda.forms import ( - BaseBdaFormSet, TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, - SoldForm + TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, SoldForm, + InscriptionInlineFormSet, ) @@ -44,39 +46,44 @@ def etat_places(request, tirage_id): Et le total de toutes les demandes """ tirage = get_object_or_404(Tirage, id=tirage_id) - spectacles1 = ChoixSpectacle.objects \ - .filter(spectacle__tirage=tirage) \ - .filter(double_choice="1") \ - .all() \ - .values('spectacle', 'spectacle__title') \ - .annotate(total=models.Count('spectacle')) - spectacles2 = ChoixSpectacle.objects \ - .filter(spectacle__tirage=tirage) \ - .exclude(double_choice="1") \ - .all() \ - .values('spectacle', 'spectacle__title') \ - .annotate(total=models.Count('spectacle')) - spectacles = tirage.spectacle_set.all() - spectacles_dict = {} - total = 0 + + spectacles = tirage.spectacle_set.select_related('location') + spectacles_dict = {} # index of spectacle by id + for spectacle in spectacles: - spectacle.total = 0 - spectacle.ratio = 0.0 + spectacle.total = 0 # init total requests spectacles_dict[spectacle.id] = spectacle - for spectacle in spectacles1: - spectacles_dict[spectacle["spectacle"]].total += spectacle["total"] - spectacles_dict[spectacle["spectacle"]].ratio = \ - spectacles_dict[spectacle["spectacle"]].total / \ - spectacles_dict[spectacle["spectacle"]].slots - total += spectacle["total"] - for spectacle in spectacles2: - spectacles_dict[spectacle["spectacle"]].total += 2*spectacle["total"] - spectacles_dict[spectacle["spectacle"]].ratio = \ - spectacles_dict[spectacle["spectacle"]].total / \ - spectacles_dict[spectacle["spectacle"]].slots - total += 2*spectacle["total"] + + choices = ( + ChoixSpectacle.objects + .filter(spectacle__in=spectacles) + .values('spectacle') + .annotate(total=Count('spectacle')) + ) + + # choices *by spectacles* whose only 1 place is requested + choices1 = choices.filter(double_choice="1") + # choices *by spectacles* whose 2 places is requested + choices2 = choices.exclude(double_choice="1") + + for spectacle in choices1: + pk = spectacle['spectacle'] + spectacles_dict[pk].total += spectacle['total'] + for spectacle in choices2: + pk = spectacle['spectacle'] + spectacles_dict[pk].total += 2*spectacle['total'] + + # here, each spectacle.total contains the number of requests + + slots = 0 # proposed slots + total = 0 # requests + for spectacle in spectacles: + slots += spectacle.slots + total += spectacle.total + spectacle.ratio = spectacle.total / spectacle.slots + context = { - "proposed": tirage.spectacle_set.aggregate(Sum('slots'))['slots__sum'], + "proposed": slots, "spectacles": spectacles, "total": total, 'tirage': tirage @@ -94,11 +101,16 @@ def _hash_queryset(queryset): @cof_required def places(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) - participant, created = Participant.objects.get_or_create( - user=request.user, tirage=tirage) - places = participant.attribution_set.order_by( - "spectacle__date", "spectacle").all() - total = sum([place.spectacle.price for place in places]) + participant, _ = ( + Participant.objects + .get_or_create(user=request.user, tirage=tirage) + ) + places = ( + participant.attribution_set + .order_by("spectacle__date", "spectacle") + .select_related("spectacle", "spectacle__location") + ) + total = sum(place.spectacle.price for place in places) filtered_places = [] places_dict = {} spectacles = [] @@ -146,35 +158,31 @@ 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, created = Participant.objects.get_or_create( - user=request.user, tirage=tirage) - choices = participant.choixspectacle_set.order_by("priority").all() + 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 formfield_callback(f, **kwargs): - """ - Fonction utilisée par inlineformset_factory ci dessous. - Restreint les spectacles proposés aux spectacles du bo tirage. - """ - if f.name == "spectacle": - kwargs['queryset'] = tirage.spectacle_set - return f.formfield(**kwargs) BdaFormSet = inlineformset_factory( Participant, ChoixSpectacle, fields=("spectacle", "double_choice", "priority"), - formset=BaseBdaFormSet, - formfield_callback=formfield_callback) - participant, created = Participant.objects.get_or_create( - user=request.user, tirage=tirage) + formset=InscriptionInlineFormSet, + ) + success = False stateerror = False if request.method == "POST": + # use *this* queryset dbstate = _hash_queryset(participant.choixspectacle_set.all()) if "dbstate" in request.POST and dbstate != request.POST["dbstate"]: stateerror = True @@ -187,9 +195,14 @@ def inscription(request, tirage_id): formset = BdaFormSet(instance=participant) else: formset = BdaFormSet(instance=participant) + # use *this* queryset dbstate = _hash_queryset(participant.choixspectacle_set.all()) total_price = 0 - for choice in participant.choixspectacle_set.all(): + choices = ( + participant.choixspectacle_set + .select_related('spectacle') + ) + for choice in choices: total_price += choice.spectacle.price if choice.double: total_price += choice.spectacle.price @@ -218,9 +231,9 @@ def do_tirage(tirage_elt, token): # Initialisation du dictionnaire data qui va contenir les résultats start = time.time() data = { - 'shows': tirage_elt.spectacle_set.select_related().all(), + 'shows': tirage_elt.spectacle_set.select_related('location'), 'token': token, - 'members': tirage_elt.participant_set.all(), + 'members': tirage_elt.participant_set.select_related('user'), 'total_slots': 0, 'total_losers': 0, 'total_sold': 0, @@ -233,7 +246,7 @@ def do_tirage(tirage_elt, token): ChoixSpectacle.objects .filter(spectacle__tirage=tirage_elt) .order_by('participant', 'priority') - .select_related().all() + .select_related('participant', 'participant__user', 'spectacle') ) results = Algorithm(data['shows'], data['members'], choices)(token) @@ -290,10 +303,30 @@ def do_tirage(tirage_elt, token): ]) # On inscrit à BdA-Revente ceux qui n'ont pas eu les places voulues - for (show, _, losers) in results: - for (loser, _, _, _) in losers: - loser.choicesrevente.add(show) - loser.save() + ChoixRevente = Participant.choicesrevente.through + + # Suppression des reventes demandées/enregistrées (si le tirage est relancé) + ( + ChoixRevente.objects + .filter(spectacle__tirage=tirage_elt) + .delete() + ) + ( + SpectacleRevente.objects + .filter(attribution__spectacle__tirage=tirage_elt) + .delete() + ) + + lost_by = defaultdict(set) + for show, _, losers in results: + for loser, _, _, _ in losers: + lost_by[loser].add(show) + + ChoixRevente.objects.bulk_create( + ChoixRevente(participant=member, spectacle=show) + for member, shows in lost_by.items() + for show in shows + ) data["duration"] = time.time() - start data["results"] = results @@ -458,7 +491,6 @@ def list_revente(request, tirage_id): ) if min_resell is not None: min_resell.answered_mail.add(participant) - min_resell.save() inscrit_revente.append(spectacle) success = True else: @@ -496,13 +528,13 @@ def buy_revente(request, spectacle_id): # Si l'utilisateur veut racheter une place qu'il est en train de revendre, # on supprime la revente en question. - if reventes.filter(seller=participant).exists(): - revente = reventes.filter(seller=participant)[0] - revente.delete() + own_reventes = reventes.filter(seller=participant) + if len(own_reventes) > 0: + own_reventes[0].delete() return HttpResponseRedirect(reverse("bda-shotgun", args=[tirage.id])) - reventes_shotgun = list(reventes.filter(shotgun=True).all()) + reventes_shotgun = reventes.filter(shotgun=True) if not reventes_shotgun: return render(request, "bda-no-revente.html", {}) @@ -534,16 +566,21 @@ def buy_revente(request, spectacle_id): @login_required def revente_shotgun(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) - spectacles = tirage.spectacle_set.filter( - date__gte=timezone.now()) - shotgun = [] - for spectacle in spectacles: - reventes = SpectacleRevente.objects.filter( - attribution__spectacle=spectacle, - shotgun=True, - soldTo__isnull=True) - if reventes.exists(): - shotgun.append(spectacle) + spectacles = ( + tirage.spectacle_set + .filter(date__gte=timezone.now()) + .select_related('location') + .prefetch_related(Prefetch( + 'attribues', + queryset=( + Attribution.objects + .filter(revente__shotgun=True, + revente__soldTo__isnull=True) + ), + to_attr='shotguns', + )) + ) + shotgun = [sp for sp in spectacles if len(sp.shotguns) > 0] return render(request, "bda-shotgun.html", {"shotgun": shotgun}) @@ -553,7 +590,10 @@ def revente_shotgun(request, tirage_id): def spectacle(request, tirage_id, spectacle_id): tirage = get_object_or_404(Tirage, id=tirage_id) spectacle = get_object_or_404(Spectacle, id=spectacle_id, tirage=tirage) - attributions = spectacle.attribues.all() + attributions = ( + spectacle.attribues + .select_related('participant', 'participant__user') + ) participants = {} for attrib in attributions: participant = attrib.participant @@ -582,7 +622,10 @@ class SpectacleListView(ListView): def get_queryset(self): self.tirage = get_object_or_404(Tirage, id=self.kwargs['tirage_id']) - categories = self.tirage.spectacle_set.all() + categories = ( + self.tirage.spectacle_set + .select_related('location') + ) return categories def get_context_data(self, **kwargs): @@ -595,9 +638,12 @@ class SpectacleListView(ListView): @buro_required def unpaid(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) - unpaid = tirage.participant_set \ - .annotate(nb_attributions=Count('attribution')) \ - .filter(paid=False, nb_attributions__gt=0).all() + unpaid = ( + tirage.participant_set + .annotate(nb_attributions=Count('attribution')) + .filter(paid=False, nb_attributions__gt=0) + .select_related('user') + ) return render(request, "bda-unpaid.html", {"unpaid": unpaid}) @@ -632,7 +678,11 @@ def send_rappel(request, spectacle_id): def descriptions_spectacles(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) - shows_qs = tirage.spectacle_set + shows_qs = ( + tirage.spectacle_set + .select_related('location') + .prefetch_related('quote_set') + ) category_name = request.GET.get('category', '') location_id = request.GET.get('location', '') if category_name: @@ -643,7 +693,7 @@ def descriptions_spectacles(request, tirage_id): except ValueError: return HttpResponseBadRequest( "La variable GET 'location' doit contenir un entier") - return render(request, 'descriptions.html', {'shows': shows_qs.all()}) + return render(request, 'descriptions.html', {'shows': shows_qs}) def catalogue(request, request_type): @@ -716,7 +766,11 @@ def catalogue(request, request_type): ) tirage = get_object_or_404(Tirage, id=tirage_id) - shows_qs = tirage.spectacle_set + shows_qs = ( + tirage.spectacle_set + .select_related('location') + .prefetch_related('quote_set') + ) if categories_id: shows_qs = shows_qs.filter(category__id__in=categories_id) if locations_id: @@ -735,14 +789,15 @@ def catalogue(request, request_type): 'vips': spectacle.vips, 'description': spectacle.description, 'slots_description': spectacle.slots_description, - 'quotes': list(Quote.objects.filter(spectacle=spectacle).values( - 'author', 'text')), + 'quotes': [dict(author=quote.author, + text=quote.text) + for quote in spectacle.quote_set.all()], 'image': spectacle.getImgUrl(), 'ext_link': spectacle.ext_link, 'price': spectacle.price, 'slots': spectacle.slots } - for spectacle in shows_qs.all() + for spectacle in shows_qs ] return JsonResponse(data_return, safe=False) # Si la requête n'est pas de la forme attendue, on quitte avec une erreur 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 61% rename from cof/settings_dev.py rename to cof/settings/common.py index 9e10d733..252a9c4f 100644 --- a/cof/settings_dev.py +++ b/cof/settings/common.py @@ -1,32 +1,41 @@ # -*- 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 server 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, + REDIS_PASSWD, REDIS_DB, REDIS_HOST, REDIS_PORT + ) +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,7 +50,6 @@ INSTALLED_APPS = ( 'autocomplete_light', 'captcha', 'django_cas_ng', - 'debug_toolbar', 'bootstrapform', 'kfet', 'channels', @@ -49,10 +57,9 @@ INSTALLED_APPS = ( 'django_js_reverse', 'custommail', 'djconfig', -) +] -MIDDLEWARE_CLASSES = ( - 'debug_toolbar.middleware.DebugToolbarMiddleware', +MIDDLEWARE_CLASSES = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -63,7 +70,7 @@ MIDDLEWARE_CLASSES = ( 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', 'djconfig.middleware.DjConfigMiddleware', -) +] ROOT_URLCONF = 'cof.urls' @@ -90,17 +97,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,19 +121,6 @@ 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 @@ -164,12 +153,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 @@ -178,28 +161,14 @@ CHANNEL_LAYERS = { "default": { "BACKEND": "asgi_redis.RedisChannelLayer", "CONFIG": { - "hosts": [(os.environ.get("REDIS_HOST", "localhost"), 6379)], + "hosts": [( + "redis://:{passwd}@{host}:{port}/{db}" + .format(passwd=REDIS_PASSWD, host=REDIS_HOST, + port=REDIS_PORT, db=REDIS_DB) + )], }, "ROUTING": "cof.routing.channel_routing", } } - -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..a3a17f99 --- /dev/null +++ b/cof/settings/dev.py @@ -0,0 +1,47 @@ +""" +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 + + +# --- +# 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. + """ + return DEBUG + +INSTALLED_APPS += ["debug_toolbar", "debug_panel"] +MIDDLEWARE_CLASSES = ( + ["debug_panel.middleware.DebugPanelMiddleware"] + + MIDDLEWARE_CLASSES +) +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..eeb5271c --- /dev/null +++ b/cof/settings/secret_example.py @@ -0,0 +1,8 @@ +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 diff --git a/gestioncof/petits_cours_views.py b/gestioncof/petits_cours_views.py index 332e156c..3fa0dc57 100644 --- a/gestioncof/petits_cours_views.py +++ b/gestioncof/petits_cours_views.py @@ -24,16 +24,22 @@ 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 + queryset = ( + PetitCoursDemande.objects + .prefetch_related('petitcoursattribution_set', + 'matieres') + ) template_name = "gestioncof/details_demande_petit_cours.html" context_object_name = "demande" 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 diff --git a/kfet/backends.py b/kfet/backends.py index ba3bce9d..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): @@ -46,6 +45,10 @@ class GenericTeamBackend(object): def get_user(self, user_id): try: - return User.objects.get(pk=user_id) + return ( + User.objects + .select_related('profile__account_kfet') + .get(pk=user_id) + ) except User.DoesNotExist: return None diff --git a/kfet/forms.py b/kfet/forms.py index f89b8f08..09c3a4d0 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -134,6 +134,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 +142,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 list(kfet_groups) + list(other_groups) class Meta: - model = User + model = User fields = ['groups'] + class GroupForm(forms.ModelForm): permissions = forms.ModelMultipleChoiceField( queryset= Permission.objects.filter(content_type__in= @@ -328,12 +328,20 @@ class KPsulAccountForm(forms.ModelForm): }), } + class KPsulCheckoutForm(forms.Form): checkout = forms.ModelChoiceField( - queryset=Checkout.objects.filter( - is_protected=False, valid_from__lte=timezone.now(), - valid_to__gte=timezone.now()), - widget=forms.Select(attrs={'id':'id_checkout_select'})) + queryset=( + Checkout.objects + .filter( + is_protected=False, + valid_from__lte=timezone.now(), + valid_to__gte=timezone.now(), + ) + ), + widget=forms.Select(attrs={'id': 'id_checkout_select'}), + ) + class KPsulOperationForm(forms.ModelForm): article = forms.ModelChoiceField( diff --git a/kfet/management/commands/createopes.py b/kfet/management/commands/createopes.py index 77663b2b..5a7699ae 100644 --- a/kfet/management/commands/createopes.py +++ b/kfet/management/commands/createopes.py @@ -14,7 +14,8 @@ from kfet.models import (Account, Article, OperationGroup, Operation, class Command(BaseCommand): - help = "Crée des opérations réparties uniformément sur une période de temps" + help = ("Crée des opérations réparties uniformément " + "sur une période de temps") def add_arguments(self, parser): # Nombre d'opérations à créer @@ -29,7 +30,6 @@ class Command(BaseCommand): parser.add_argument('--transfers', type=int, default=0, help='Number of transfers to create (default 0)') - def handle(self, *args, **options): self.stdout.write("Génération d'opérations") @@ -44,6 +44,7 @@ class Command(BaseCommand): # Convert to seconds time = options['days'] * 24 * 3600 + now = timezone.now() checkout = Checkout.objects.first() articles = Article.objects.all() accounts = Account.objects.exclude(trigramme='LIQ') @@ -55,6 +56,13 @@ class Command(BaseCommand): except Account.DoesNotExist: con_account = random.choice(accounts) + # use to fetch OperationGroup pk created by bulk_create + at_list = [] + # use to lazy set OperationGroup pk on Operation objects + ope_by_grp = [] + # OperationGroup objects to bulk_create + opegroup_list = [] + for i in range(num_ops): # Randomly pick account @@ -64,8 +72,7 @@ class Command(BaseCommand): account = liq_account # Randomly pick time - at = timezone.now() - timedelta( - seconds=random.randint(0, time)) + at = now - timedelta(seconds=random.randint(0, time)) # Majoration sur compte 'concert' if random.random() < 0.2: @@ -78,13 +85,6 @@ class Command(BaseCommand): # Initialize opegroup amount amount = Decimal('0') - opegroup = OperationGroup.objects.create( - on_acc=account, - checkout=checkout, - at=at, - is_cof=account.cofprofile.is_cof - ) - # Generating operations ope_list = [] for j in range(random.randint(1, 4)): @@ -94,25 +94,26 @@ class Command(BaseCommand): # 0.1 probability to have a charge if typevar > 0.9 and account != liq_account: ope = Operation( - group=opegroup, type=Operation.DEPOSIT, - is_checkout=(random.random() > 0.2), amount=Decimal(random.randint(1, 99)/10) ) - # 0.1 probability to have a withdrawal + # 0.05 probability to have a withdrawal + elif typevar > 0.85 and account != liq_account: + ope = Operation( + type=Operation.WITHDRAW, + amount=-Decimal(random.randint(1, 99)/10) + ) + # 0.05 probability to have an edition elif typevar > 0.8 and account != liq_account: ope = Operation( - group=opegroup, - type=Operation.WITHDRAW, - is_checkout=(random.random() > 0.2), - amount=-Decimal(random.randint(1, 99)/10) + type=Operation.EDIT, + amount=Decimal(random.randint(1, 99)/10) ) else: article = random.choice(articles) nb = random.randint(1, 5) ope = Operation( - group=opegroup, type=Operation.PURCHASE, amount=-article.price*nb, article=article, @@ -129,17 +130,44 @@ class Command(BaseCommand): ope_list.append(ope) amount += ope.amount - Operation.objects.bulk_create(ope_list) - opes_created += len(ope_list) - opegroup.amount = amount - opegroup.save() + opegroup_list.append(OperationGroup( + on_acc=account, + checkout=checkout, + at=at, + is_cof=account.cofprofile.is_cof, + amount=amount, + )) + at_list.append(at) + ope_by_grp.append((at, ope_list, )) + + OperationGroup.objects.bulk_create(opegroup_list) + + # Fetch created OperationGroup objects pk by at + opegroups = (OperationGroup.objects + .filter(at__in=at_list) + .values('id', 'at')) + opegroups_by = {grp['at']: grp['id'] for grp in opegroups} + + all_ope = [] + for _ in range(num_ops): + at, ope_list = ope_by_grp.pop() + for ope in ope_list: + ope.group_id = opegroups_by[at] + all_ope.append(ope) + + Operation.objects.bulk_create(all_ope) + opes_created = len(all_ope) # Transfer generation + + transfer_by_grp = [] + transfergroup_list = [] + at_list = [] + for i in range(num_transfers): # Randomly pick time - at = timezone.now() - timedelta( - seconds=random.randint(0, time)) + at = now - timedelta(seconds=random.randint(0, time)) # Choose whether to have a comment if random.random() > 0.5: @@ -147,24 +175,40 @@ class Command(BaseCommand): else: comment = "" - transfergroup = TransferGroup.objects.create( + transfergroup_list.append(TransferGroup( at=at, comment=comment, - valid_by=random.choice(accounts) - ) + valid_by=random.choice(accounts), + )) + at_list.append(at) # Randomly generate transfer transfer_list = [] for i in range(random.randint(1, 4)): transfer_list.append(Transfer( - group=transfergroup, from_acc=random.choice(accounts), to_acc=random.choice(accounts), amount=Decimal(random.randint(1, 99)/10) )) - Transfer.objects.bulk_create(transfer_list) - transfers += len(transfer_list) + transfer_by_grp.append((at, transfer_list, )) + + TransferGroup.objects.bulk_create(transfergroup_list) + + transfergroups = (TransferGroup.objects + .filter(at__in=at_list) + .values('id', 'at')) + transfergroups_by = {grp['at']: grp['id'] for grp in transfergroups} + + all_transfer = [] + for _ in range(num_transfers): + at, transfer_list = transfer_by_grp.pop() + for transfer in transfer_list: + transfer.group_id = transfergroups_by[at] + all_transfer.append(transfer) + + Transfer.objects.bulk_create(all_transfer) + transfers += len(all_transfer) self.stdout.write( "- {:d} opérations créées dont {:d} commandes d'articles" diff --git a/kfet/middleware.py b/kfet/middleware.py index dbb192c6..9502d393 100644 --- a/kfet/middleware.py +++ b/kfet/middleware.py @@ -1,15 +1,27 @@ # -*- coding: utf-8 -*- -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * +from django.contrib.auth.models import User -from django.http import HttpResponseForbidden from kfet.backends import KFetBackend -from kfet.models import Account + class KFetAuthenticationMiddleware(object): + """Authenticate another user for this request if KFetBackend succeeds. + + By the way, if a user is authenticated, we refresh its from db to add + values from CofProfile and Account of this user. + + """ def process_request(self, request): + if request.user.is_authenticated(): + # avoid multiple db accesses in views and templates + user_pk = request.user.pk + request.user = ( + User.objects + .select_related('profile__account_kfet') + .get(pk=user_pk) + ) + kfet_backend = KFetBackend() temp_request_user = kfet_backend.authenticate(request) if temp_request_user: diff --git a/kfet/models.py b/kfet/models.py index 6c1f1240..cc35df18 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -23,8 +23,19 @@ def default_promo(): now = date.today() return now.month <= 8 and now.year-1 or now.year -@python_2_unicode_compatible + +class AccountManager(models.Manager): + """Manager for Account Model.""" + + def get_queryset(self): + """Always append related data to this Account.""" + return super().get_queryset().select_related('cofprofile__user', + 'negative') + + class Account(models.Model): + objects = AccountManager() + cofprofile = models.OneToOneField( CofProfile, on_delete = models.PROTECT, related_name = "account_kfet") @@ -237,27 +248,43 @@ class Account(models.Model): def __init__(self, trigramme): self.trigramme = trigramme + +class AccountNegativeManager(models.Manager): + """Manager for AccountNegative model.""" + + def get_queryset(self): + return ( + super().get_queryset() + .select_related('account__cofprofile__user') + ) + + class AccountNegative(models.Model): + objects = AccountNegativeManager() + account = models.OneToOneField( - Account, on_delete = models.PROTECT, - related_name = "negative") - start = models.DateTimeField( - blank = True, null = True, default = None) + Account, on_delete=models.PROTECT, + related_name="negative", + ) + start = models.DateTimeField(blank=True, null=True, default=None) balance_offset = models.DecimalField( "décalage de balance", help_text="Montant non compris dans l'autorisation de négatif", - max_digits = 6, decimal_places = 2, - blank = True, null = True, default = None) + max_digits=6, decimal_places=2, + blank=True, null=True, default=None, + ) authz_overdraft_amount = models.DecimalField( "négatif autorisé", - max_digits = 6, decimal_places = 2, - blank = True, null = True, default = None) + max_digits=6, decimal_places=2, + blank=True, null=True, default=None, + ) authz_overdraft_until = models.DateTimeField( "expiration du négatif", - blank = True, null = True, default = None) - comment = models.CharField("commentaire", max_length = 255, blank = True) + blank=True, null=True, default=None, + ) + comment = models.CharField("commentaire", max_length=255, blank=True) + -@python_2_unicode_compatible class Checkout(models.Model): created_by = models.ForeignKey( Account, on_delete = models.PROTECT, 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..8ffb7db5 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 @@ -81,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' @@ -222,13 +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 - ] 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/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/tests/test_forms.py b/kfet/tests/test_forms.py new file mode 100644 index 00000000..7f129a3f --- /dev/null +++ b/kfet/tests/test_forms.py @@ -0,0 +1,56 @@ +# -*- 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.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.""" + 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.assertQuerysetEqual( + user.groups.all(), + [repr(g) for g in [self.other_group] + self.kfet_groups], + ordered=False, + ) diff --git a/kfet/views.py b/kfet/views.py index acd9f0a2..6751d95b 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 @@ -83,11 +83,21 @@ class Home(TemplateView): template_name = "kfet/home.html" def get_context_data(self, **kwargs): - context = super(TemplateView, self).get_context_data(**kwargs) - articles = Article.objects.all().filter(is_sold=True, hidden=False) - context['pressions'] = articles.filter(category__name='Pression') - context['articles'] = (articles.exclude(category__name='Pression') - .order_by('category')) + context = super().get_context_data(**kwargs) + articles = list( + Article.objects + .filter(is_sold=True, hidden=False) + .select_related('category') + .order_by('category__name') + ) + pressions, others = [], [] + while len(articles) > 0: + article = articles.pop() + if article.category.name == 'Pression': + pressions.append(article) + else: + others.append(article) + context['pressions'], context['articles'] = pressions, others return context @method_decorator(login_required) @@ -379,11 +389,7 @@ 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)) - except Account.DoesNotExist: - raise Http404 + account = get_object_or_404(Account, trigramme=trigramme) # Checking permissions if not request.user.has_perm('kfet.is_team') \ @@ -399,7 +405,8 @@ def account_read(request, trigramme): addcosts = ( OperationGroup.objects - .filter(opes__addcost_for=account, opes__canceled_at=None) + .filter(opes__addcost_for=account, + opes__canceled_at=None) .extra({'date': "date(at)"}) .values('date') .annotate(sum_addcosts=Sum('opes__addcost_amount')) @@ -556,13 +563,22 @@ def account_update(request, trigramme): 'pwd_form': pwd_form, }) + @permission_required('kfet.manage_perms') def account_group(request): - groups = (Group.objects - .filter(name__icontains='K-Fêt') - .prefetch_related('permissions', 'user_set__profile__account_kfet') + user_pre = Prefetch( + 'user_set', + queryset=User.objects.select_related('profile__account_kfet'), ) - return render(request, 'kfet/account_group.html', { 'groups': groups }) + groups = ( + Group.objects + .filter(name__icontains='K-Fêt') + .prefetch_related('permissions', user_pre) + ) + return render(request, 'kfet/account_group.html', { + 'groups': groups, + }) + class AccountGroupCreate(SuccessMessageMixin, CreateView): model = Group @@ -578,23 +594,20 @@ class AccountGroupUpdate(UpdateView): success_message = 'Groupe modifié : %(name)s' success_url = reverse_lazy('kfet.account.group') + class AccountNegativeList(ListView): - queryset = (AccountNegative.objects + queryset = ( + AccountNegative.objects .select_related('account', 'account__cofprofile__user') - .exclude(account__trigramme='#13')) + .exclude(account__trigramme='#13') + ) template_name = 'kfet/account_negative.html' context_object_name = 'negatives' def get_context_data(self, **kwargs): context = super(AccountNegativeList, self).get_context_data(**kwargs) - negs_sum = (AccountNegative.objects - .exclude(account__trigramme='#13') - .aggregate( - bal = Coalesce(Sum('account__balance'),0), - offset = Coalesce(Sum('balance_offset'),0), - ) - ) - context['negatives_sum'] = negs_sum['bal'] - negs_sum['offset'] + real_balances = (neg.account.real_balance for neg in self.object_list) + context['negatives_sum'] = sum(real_balances) return context # ----- @@ -817,12 +830,18 @@ class CategoryUpdate(SuccessMessageMixin, UpdateView): # Article - General class ArticleList(ListView): - queryset = (Article.objects - .select_related('category') - .prefetch_related(Prefetch('inventories', - queryset=Inventory.objects.order_by('-at'), - to_attr='inventory')) - .order_by('category', '-is_sold', 'name')) + queryset = ( + Article.objects + .select_related('category') + .prefetch_related( + Prefetch( + 'inventories', + queryset=Inventory.objects.order_by('-at'), + to_attr='inventory', + ) + ) + .order_by('category__name', '-is_sold', 'name') + ) template_name = 'kfet/article.html' context_object_name = 'articles' @@ -934,7 +953,6 @@ class ArticleUpdate(SuccessMessageMixin, UpdateView): return super(ArticleUpdate, self).form_valid(form) - # ----- # K-Psul # ----- @@ -944,17 +962,10 @@ def kpsul(request): data = {} data['operationgroup_form'] = KPsulOperationGroupForm() data['trigramme_form'] = KPsulAccountForm() - initial = {} - try: - checkout = Checkout.objects.filter( - is_protected=False, valid_from__lte=timezone.now(), - valid_to__gte=timezone.now()).get() - initial['checkout'] = checkout - except (Checkout.DoesNotExist, Checkout.MultipleObjectsReturned): - pass - data['checkout_form'] = KPsulCheckoutForm(initial=initial) - operation_formset = KPsulOperationFormSet(queryset=Operation.objects.none()) - data['operation_formset'] = operation_formset + data['checkout_form'] = KPsulCheckoutForm() + data['operation_formset'] = KPsulOperationFormSet( + queryset=Operation.objects.none(), + ) return render(request, 'kfet/kpsul.html', data) @@ -1686,6 +1697,7 @@ class SettingsUpdate(SuccessMessageMixin, FormView): return super().form_valid(form) + # ----- # Transfer views # ----- @@ -1695,6 +1707,7 @@ class SettingsUpdate(SuccessMessageMixin, FormView): def transfers(request): return render(request, 'kfet/transfers.html') + @teamkfet_required def transfers_create(request): transfer_formset = TransferFormSet(queryset=Transfer.objects.none()) @@ -1907,68 +1920,54 @@ 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 = [ + {d['article']: d['nb'] for d in chunk} + for chunk in chunks + ] + + 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) - v_all = [v_s1, v_s2, v_s3, v_s4, v_s5] + # Get sales for each 5 last weeks + 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 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: @@ -1986,11 +1985,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), @@ -1998,8 +1993,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) @@ -2016,14 +2012,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) @@ -2035,9 +2032,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' @@ -2076,6 +2074,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) @@ -2083,28 +2082,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, @@ -2119,31 +2126,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: @@ -2305,10 +2331,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: @@ -2336,13 +2365,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, }) @@ -2350,21 +2377,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 ] @@ -2457,13 +2481,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 = scale.get_by_chunks( + all_operations, field_db='group__at', + field_callback=(lambda d: d['group__at']), + ) return chunks def get_context_data(self, *args, **kwargs): @@ -2479,7 +2509,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", @@ -2530,29 +2561,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 = scale.get_by_chunks( + liq_only, field_db='group__at', + field_callback=lambda d: d['group__at'], + ) + chunks_no_liq = scale.get_by_chunks( + liq_exclude, 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}, diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index 269e4f25..38efdfb5 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -23,6 +23,11 @@ apt-get install -y mysql-server mysql -uroot -p$DBPASSWD -e "CREATE DATABASE $DBNAME; GRANT ALL PRIVILEGES ON $DBNAME.* TO '$DBUSER'@'localhost' IDENTIFIED BY '$DBPASSWD'" mysql -uroot -p$DBPASSWD -e "GRANT ALL PRIVILEGES ON test_$DBNAME.* TO '$DBUSER'@'localhost'" +# Configuration de redis +REDIS_PASSWD="dummy" +redis-cli CONFIG SET requirepass $REDIS_PASSWD +redis-cli -a $REDIS_PASSWD CONFIG REWRITE + # Installation et configuration d'Apache apt-get install -y apache2 a2enmod proxy proxy_http proxy_wstunnel headers @@ -36,7 +41,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 <