Merge branch 'master' into aureplop/clean_scroll

This commit is contained in:
Aurélien Delobelle 2017-05-19 14:08:57 +02:00
commit ecce2fda21
24 changed files with 896 additions and 427 deletions

View file

@ -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):
"""

View file

@ -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:

View file

@ -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):
@ -96,4 +112,6 @@ class SoldForm(forms.Form):
.filter(revente__isnull=False,
revente__soldTo__isnull=False)
.exclude(revente__soldTo=participant)
.select_related('spectacle', 'spectacle__location',
'participant__user')
)

View file

@ -4,6 +4,8 @@
{% block realcontent %}
<h2>Revente de place</h2>
{% with resell_attributions=resellform.attributions annul_attributions=annulform.attributions sold_attributions=soldform.attributions %}
{% if resellform.attributions %}
<h3>Places non revendues</h3>
<form class="form-horizontal" action="" method="post">
@ -15,14 +17,14 @@
</form>
{% endif %}
<br>
{% if annulform.attributions or overdue %}
{% if annul_attributions or overdue %}
<h3>Places en cours de revente</h3>
<form action="" method="post">
{% csrf_token %}
<div class='form-group'>
<div class='multiple-checkbox'>
<ul>
{% for attrib in annulform.attributions %}
{% for attrib in annul_attributions %}
<li>{{attrib.tag}} {{attrib.choice_label}}</li>
{% endfor %}
{% for attrib in overdue %}
@ -31,13 +33,13 @@
{{attrib.spectacle}}
</li>
{% endfor %}
{% if annulform.attributions %}
{% if annul_attributions %}
<input type="submit" class="btn btn-primary" name="annul" value="Annuler les reventes sélectionnées">
{% endif %}
</form>
{% endif %}
<br>
{% if soldform.attributions %}
{% if sold_attributions %}
<h3>Places revendues</h3>
<form action="" method="post">
{% csrf_token %}
@ -46,8 +48,9 @@
<button type="submit" class="btn btn-primary" name="reinit">Réinitialiser</button>
</form>
{% 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 %}
<p>Plus de reventes possibles !</p>
{% endif %}
{% endwith %}
{% endblock %}

View file

@ -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

View file

@ -22,7 +22,8 @@ except KeyError:
# Other secrets
try:
from .secret import (
SECRET_KEY, RECAPTCHA_PUBLIC_KEY, RECAPTCHA_PRIVATE_KEY, ADMINS
SECRET_KEY, RECAPTCHA_PUBLIC_KEY, RECAPTCHA_PRIVATE_KEY, ADMINS,
REDIS_PASSWD, REDIS_DB, REDIS_HOST, REDIS_PORT
)
except ImportError:
raise RuntimeError("Secrets missing")
@ -159,7 +160,11 @@ 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",
}

View file

@ -35,15 +35,11 @@ def show_toolbar(request):
machine physique n'est pas forcément connue, et peut difficilement être
mise dans les INTERNAL_IPS.
"""
if not DEBUG:
return False
if request.is_ajax():
return False
return True
return DEBUG
INSTALLED_APPS += ["debug_toolbar"]
INSTALLED_APPS += ["debug_toolbar", "debug_panel"]
MIDDLEWARE_CLASSES = (
["debug_toolbar.middleware.DebugToolbarMiddleware"]
["debug_panel.middleware.DebugPanelMiddleware"]
+ MIDDLEWARE_CLASSES
)
DEBUG_TOOLBAR_CONFIG = {

View file

@ -1,4 +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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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,11 +142,9 @@ 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
@ -343,12 +342,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(

View file

@ -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"

View file

@ -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:

View file

@ -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)
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)
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)
blank=True, null=True, default=None,
)
authz_overdraft_until = models.DateTimeField(
"expiration du négatif",
blank = True, null = True, default = None)
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,

View file

@ -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,
}
};

View file

@ -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
]

View file

@ -104,7 +104,7 @@
$(document).ready(function() {
var stat_last = new StatsGroup(
"{% url 'kfet.article.stat.sales.list' article.id %}",
$("#stat_last"),
$("#stat_last")
);
});
</script>

View file

@ -196,6 +196,8 @@ $(document).ready(function() {
$('input[type="submit"]').on("click", function(e) {
e.preventDefault();
var content;
if (conflicts.size) {
content = '';
content += "Conflits possibles :"

View file

@ -182,7 +182,7 @@ $(document).ready(function() {
settings = {}
function resetSettings() {
$.ajax({
return $.ajax({
dataType: "json",
url : "{% url 'kfet.kpsul.get_settings' %}",
method : "POST",
@ -191,7 +191,6 @@ $(document).ready(function() {
settings['addcost_for'] = data['addcost_for'];
settings['addcost_amount'] = parseFloat(data['addcost_amount']);
settings['subvention_cof'] = parseFloat(data['subvention_cof']);
displayAddcost();
});
}
@ -393,6 +392,11 @@ $(document).ready(function() {
var last_statement_container = $('#last_statement');
var last_statement_html_default = '<b>Dernier relevé: </b><br><span id="checkout-last_statement_balance"></span>€ le <span id="checkout-last_statement_at"></span> par <span id="checkout-last_statement_by_trigramme"></span>';
// If only one checkout is available, select it
var checkout_choices = checkoutInput.find("option[value!='']");
if (checkout_choices.length == 1) {
$(checkout_choices[0]).prop("selected", true);
}
// Display data
function displayCheckoutData() {
@ -1426,9 +1430,11 @@ $(document).ready(function() {
resetArticles();
resetPreviousOp();
khistory.reset();
resetSettings();
resetSettings().done(function (){
getArticles();
getHistory();
displayAddcost();
});
}
function resetSelectable() {

56
kfet/tests/test_forms.py Normal file
View file

@ -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,
)

View file

@ -50,18 +50,28 @@ from decimal import Decimal
import django_cas_ng
import heapq
import statistics
from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes
from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes, WeekScale
class Home(TemplateView):
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)
@ -355,18 +365,15 @@ 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') \
and request.user != account.user:
raise PermissionDenied
addcosts = (OperationGroup.objects
addcosts = (
OperationGroup.objects
.filter(opes__addcost_for=account,
opes__canceled_at=None)
.extra({'date': "date(at)"})
@ -524,13 +531,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
@ -546,23 +562,20 @@ class AccountGroupUpdate(SuccessMessageMixin, 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
# -----
@ -765,12 +778,18 @@ class CategoryUpdate(SuccessMessageMixin, UpdateView):
# Article - General
class ArticleList(ListView):
queryset = (Article.objects
queryset = (
Article.objects
.select_related('category')
.prefetch_related(Prefetch('inventories',
.prefetch_related(
Prefetch(
'inventories',
queryset=Inventory.objects.order_by('-at'),
to_attr='inventory'))
.order_by('category', '-is_sold', 'name'))
to_attr='inventory',
)
)
.order_by('category__name', '-is_sold', 'name')
)
template_name = 'kfet/article.html'
context_object_name = 'articles'
@ -882,7 +901,6 @@ class ArticleUpdate(SuccessMessageMixin, UpdateView):
return super(ArticleUpdate, self).form_valid(form)
# -----
# K-Psul
# -----
@ -892,17 +910,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)
@ -1483,19 +1494,32 @@ class SettingsUpdate(SuccessMessageMixin, FormView):
return super().form_valid(form)
# -----
# Transfer views
# -----
@teamkfet_required
def transfers(request):
transfergroups = (TransferGroup.objects
.prefetch_related('transfers')
.order_by('-at'))
transfers_pre = Prefetch(
'transfers',
queryset=(
Transfer.objects
.select_related('from_acc', 'to_acc')
),
)
transfergroups = (
TransferGroup.objects
.select_related('valid_by')
.prefetch_related(transfers_pre)
.order_by('-at')
)
return render(request, 'kfet/transfers.html', {
'transfergroups': transfergroups,
})
@teamkfet_required
def transfers_create(request):
transfer_formset = TransferFormSet(queryset=Transfer.objects.none())
@ -1639,9 +1663,6 @@ def cancel_transfers(request):
if stop:
negative_accounts.append(account.trigramme)
print(required_perms)
print(request.user.get_all_permissions())
if stop_all or not request.user.has_perms(required_perms):
missing_perms = get_missing_perms(required_perms, request.user)
if missing_perms:
@ -1779,68 +1800,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
articles = (
Article.objects
.filter(suppliers=supplier.pk)
.distinct()
.select_related('category')
.order_by('category__name', 'name'))
.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:
@ -1858,11 +1865,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),
@ -1871,7 +1874,8 @@ def order_create(request, pk):
cls_formset = formset_factory(
form=OrderArticleForm,
extra = 0)
extra=0,
)
if request.POST:
formset = cls_formset(request.POST, initial=initial)
@ -1888,14 +1892,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)
quantity_ordered=q_ordered,
)
if saved:
messages.success(request, 'Commande créée')
return redirect('kfet.order.read', order.pk)
@ -1910,6 +1915,7 @@ def order_create(request, pk):
'formset': formset,
})
class OrderRead(DetailView):
model = Order
template_name = 'kfet/order_read.html'
@ -1948,6 +1954,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)
@ -1955,28 +1962,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
supplier_prefetch = Prefetch(
'article__supplierarticle_set',
queryset=(
SupplierArticle.objects
.filter(supplier=order.supplier)
.order_by('-at')),
to_attr = 'supplier'))
.order_by('category__name', 'name'))
.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,
@ -1991,31 +2006,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(
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 = form.cleaned_data['price_HT'],
TVA = form.cleaned_data['TVA'],
rights = form.cleaned_data['rights'])
(OrderArticle.objects
price_HT=price_HT,
TVA=TVA,
rights=rights,
)
)
(
OrderArticle.objects
.filter(order=order, article=article)
.update(quantity_received = q_received))
InventoryArticle.objects.create(
.update(quantity_received=q_received)
)
new_inventoryarticle.append(
InventoryArticle(
inventory=inventory,
article=article,
stock_old=article.stock,
stock_new = article.stock + q_received)
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:
@ -2200,10 +2234,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:
@ -2231,13 +2268,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,
})
@ -2245,21 +2280,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
]
@ -2352,13 +2384,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):
@ -2374,7 +2412,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",
@ -2425,29 +2464,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,
.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},

View file

@ -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
@ -61,10 +66,8 @@ sudo -H -u ubuntu python3 -m venv ~ubuntu/venv
sudo -H -u ubuntu ~ubuntu/venv/bin/pip install -U pip
sudo -H -u ubuntu ~ubuntu/venv/bin/pip install -r requirements.txt -r requirements-devel.txt
# Installation des secrets
sudo -H -u ubuntu cp cof/settings/secret_example.py cof/settings/secret.py
# Préparation de Django
if [ ! -f cof/settings/secret.py ]; then sudo -H -u ubuntu ln -s secret_example.py cof/settings/secret.py; fi
sudo -H -u ubuntu DJANGO_SETTINGS_MODULE='cof.settings.dev' DBUSER=$DBUSER DBNAME=$DBNAME DBPASSWD=$DBPASSWD bash provisioning/prepare_django.sh
# Installation du cron pour les mails de rappels

View file

@ -1,3 +1,4 @@
-r requirements.txt
django-debug-toolbar
django-debug-panel
ipython