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 59% rename from cof/settings_dev.py rename to cof/settings/common.py index 18aadaad..261760d6 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,16 +50,15 @@ INSTALLED_APPS = ( 'autocomplete_light', 'captcha', 'django_cas_ng', - 'debug_toolbar', 'bootstrapform', 'kfet', 'channels', 'widget_tweaks', 'custommail', -) + 'djconfig', +] -MIDDLEWARE_CLASSES = ( - 'debug_toolbar.middleware.DebugToolbarMiddleware', +MIDDLEWARE_CLASSES = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -60,7 +68,8 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', -) + 'djconfig.middleware.DjConfigMiddleware', +] ROOT_URLCONF = 'cof.urls' @@ -78,24 +87,21 @@ 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', ], }, }, ] -# 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'), } } @@ -114,19 +120,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 @@ -159,12 +152,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 @@ -173,28 +160,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/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/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/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..09c3a4d0 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 # ----- @@ -128,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'), @@ -135,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= @@ -322,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( @@ -389,40 +403,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/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/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 2ad1c091..cc35df18 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) @@ -27,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") @@ -85,7 +92,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 +120,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,31 +221,70 @@ 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 + +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, @@ -632,116 +678,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/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/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 3c2ccbcd..b55f2b99 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -8,7 +8,6 @@ - {% if account.user == request.user %} @@ -18,11 +17,11 @@ $(document).ready(function() { var stat_last = new StatsGroup( "{% url 'kfet.account.stat.operation.list' trigramme=account.trigramme %}", - $("#stat_last"), + $("#stat_last") ); var stat_balance = new StatsGroup( "{% url 'kfet.account.stat.balance.list' trigramme=account.trigramme %}", - $("#stat_balance"), + $("#stat_balance") ); }); @@ -56,51 +55,46 @@ $(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/history.html b/kfet/templates/kfet/history.html index 091b4f2f..ab1eef72 100644 --- a/kfet/templates/kfet/history.html +++ b/kfet/templates/kfet/history.html @@ -60,7 +60,7 @@