# -*- coding: utf-8 -*- from collections import defaultdict from functools import partial import random import hashlib import time import json from datetime import timedelta from custommail.shortcuts import ( send_mass_custom_mail, send_custom_mail, render_custom_mail ) 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 transaction from django.core import serializers from django.db.models import Count, Q from django.forms.models import inlineformset_factory from django.http import ( HttpResponseBadRequest, HttpResponseRedirect, JsonResponse ) from django.core.urlresolvers import reverse from django.conf import settings from django.utils import timezone, formats from django.views.generic.list import ListView from gestioncof.decorators import cof_required, buro_required from bda.models import ( Spectacle, Participant, ChoixSpectacle, Attribution, Tirage, SpectacleRevente, Salle, Quote, CategorieSpectacle ) from bda.algorithm import Algorithm from bda.forms import ( TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, SoldForm, ) @cof_required def etat_places(request, tirage_id): """ Résumé des spectacles d'un tirage avec pour chaque spectacle : - Le nombre de places en jeu - Le nombre de demandes - Le ratio demandes/places Et le total de toutes les demandes """ tirage = get_object_or_404(Tirage, id=tirage_id) spectacles = tirage.spectacle_set.select_related('location') spectacles_dict = {} # index of spectacle by id for spectacle in spectacles: spectacle.total = 0 # init total requests spectacles_dict[spectacle.id] = spectacle 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": slots, "spectacles": spectacles, "total": total, 'tirage': tirage } return render(request, "bda/etat-places.html", context) def _hash_queryset(queryset): data = serializers.serialize("json", queryset).encode('utf-8') hasher = hashlib.sha256() hasher.update(data) return hasher.hexdigest() @cof_required def places(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) 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 = [] dates = [] warning = False for place in places: if place.spectacle in spectacles: places_dict[place.spectacle].double = True else: place.double = False places_dict[place.spectacle] = place spectacles.append(place.spectacle) filtered_places.append(place) date = place.spectacle.date.date() if date in dates: warning = True else: dates.append(date) # On prévient l'utilisateur s'il a deux places à la même date if warning: messages.warning(request, "Attention, vous avez reçu des places pour " "des spectacles différents à la même date.") return render(request, "bda/resume_places.html", {"participant": participant, "places": filtered_places, "tirage": tirage, "total": total}) @cof_required def inscription(request, tirage_id): """ Vue d'inscription à un tirage BdA. - On vérifie qu'on se situe bien entre la date d'ouverture et la date de fermeture des inscriptions. - On vérifie que l'inscription n'a pas été modifiée entre le moment où le client demande le formulaire et le moment où il soumet son inscription (autre session par exemple). """ tirage = get_object_or_404(Tirage, id=tirage_id) if timezone.now() < tirage.ouverture: # Le tirage n'est pas encore ouvert. opening = formats.localize( timezone.template_localtime(tirage.ouverture)) messages.error(request, "Le tirage n'est pas encore ouvert : " "ouverture le {:s}".format(opening)) return render(request, 'bda/resume-inscription-tirage.html', {}) if timezone.now() > tirage.fermeture: # Le tirage est fermé. participant, _ = ( Participant.objects .get_or_create(user=request.user, tirage=tirage) ) choices = participant.choixspectacle_set.order_by("priority") messages.error(request, " C'est fini : tirage au sort dans la journée !") return render(request, "bda/resume-inscription-tirage.html", {"choices": choices}) def force_for(f, to_choices=None, **kwargs): """Overrides choices for ModelChoiceField. Args: f (models.Field): To render as forms.Field to_choices (dict): If a key `f.name` exists, f._choices is set to its value. """ formfield = f.formfield(**kwargs) if to_choices: if f.name in to_choices: choices = [('', '---------')] + to_choices[f.name] formfield._choices = choices return formfield # Restrict spectacles choices to spectacles for this tirage. spectacles = ( tirage.spectacle_set .select_related('location') ) spectacles_field_choices = [(sp.pk, str(sp)) for sp in spectacles] # Allow for spectacle choices to be set once for all. # Form display use 1 request instead of (#forms of formset * #spectacles). # FIXME: Validation still generates too much requests... formfield_callback = partial( force_for, to_choices={ 'spectacle': spectacles_field_choices, }, ) BdaFormSet = inlineformset_factory( Participant, ChoixSpectacle, fields=("spectacle", "double_choice", "priority"), formfield_callback=formfield_callback, ) participant, _ = ( Participant.objects .get_or_create(user=request.user, tirage=tirage) ) 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 formset = BdaFormSet(instance=participant) else: formset = BdaFormSet(request.POST, instance=participant) if formset.is_valid(): formset.save() success = True formset = BdaFormSet(instance=participant) else: formset = BdaFormSet(instance=participant) # use *this* queryset dbstate = _hash_queryset(participant.choixspectacle_set.all()) total_price = 0 choices = ( participant.choixspectacle_set .select_related('spectacle') ) for choice in choices: total_price += choice.spectacle.price if choice.double: total_price += choice.spectacle.price # Messages if success: messages.success(request, "Votre inscription a été mise à jour avec " "succès !") if stateerror: messages.error(request, "Impossible d'enregistrer vos modifications " ": vous avez apporté d'autres modifications " "entre temps.") return render(request, "bda/inscription-tirage.html", {"formset": formset, "total_price": total_price, "dbstate": dbstate, 'tirage': tirage}) def do_tirage(tirage_elt, token): """ Fonction auxiliaire à la vue ``tirage`` qui lance effectivement le tirage après qu'on a vérifié que c'est légitime et que le token donné en argument est correct. Rend les résultats """ # Initialisation du dictionnaire data qui va contenir les résultats start = time.time() data = { 'shows': tirage_elt.spectacle_set.select_related('location'), 'token': token, 'members': tirage_elt.participant_set.select_related('user'), 'total_slots': 0, 'total_losers': 0, 'total_sold': 0, 'total_deficit': 0, 'opera_deficit': 0, } # On lance le tirage choices = ( ChoixSpectacle.objects .filter(spectacle__tirage=tirage_elt) .order_by('participant', 'priority') .select_related('participant', 'participant__user', 'spectacle') ) results = Algorithm(data['shows'], data['members'], choices)(token) # On compte les places attribuées et les déçus for (_, members, losers) in results: data['total_slots'] += len(members) data['total_losers'] += len(losers) # On calcule le déficit et les bénéfices pour le BdA # FIXME: le traitement de l'opéra est sale for (show, members, _) in results: deficit = (show.slots - len(members)) * show.price data['total_sold'] += show.slots * show.price if deficit >= 0: if "Opéra" in show.location.name: data['opera_deficit'] += deficit data['total_deficit'] += deficit data["total_sold"] -= data['total_deficit'] # Participant objects are not shared accross spectacle results, # so assign a single object for each Participant id members_uniq = {} members2 = {} for (show, members, _) in results: for (member, _, _, _) in members: if member.id not in members_uniq: members_uniq[member.id] = member members2[member] = [] member.total = 0 member = members_uniq[member.id] members2[member].append(show) member.total += show.price members2 = members2.items() data["members2"] = sorted(members2, key=lambda m: m[0].user.last_name) # --- # À partir d'ici, le tirage devient effectif # --- # On suppression les vieilles attributions, on sauvegarde le token et on # désactive le tirage Attribution.objects.filter(spectacle__tirage=tirage_elt).delete() tirage_elt.tokens += '{:s}\n"""{:s}"""\n'.format( timezone.now().strftime("%y-%m-%d %H:%M:%S"), token) tirage_elt.enable_do_tirage = False tirage_elt.save() # On enregistre les nouvelles attributions Attribution.objects.bulk_create([ Attribution(spectacle=show, participant=member) for show, members, _ in results for member, _, _, _ in members ]) # On inscrit à BdA-Revente ceux qui n'ont pas eu les places voulues ChoixRevente = Participant.choicesrevente.through 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 return data @buro_required def tirage(request, tirage_id): tirage_elt = get_object_or_404(Tirage, id=tirage_id) if not (tirage_elt.enable_do_tirage and tirage_elt.fermeture < timezone.now()): return render(request, "tirage-failed.html", {'tirage': tirage_elt}) if request.POST: form = TokenForm(request.POST) if form.is_valid(): results = do_tirage(tirage_elt, form.cleaned_data['token']) return render(request, "bda-attrib-extra.html", results) else: form = TokenForm() return render(request, "bda-token.html", {"form": form}) @login_required def revente(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) participant, created = Participant.objects.get_or_create( user=request.user, tirage=tirage) if not participant.paid: return render(request, "bda-notpaid.html", {}) resellform = ResellForm(participant, prefix='resell') annulform = AnnulForm(participant, prefix='annul') soldform = SoldForm(participant, prefix='sold') if request.method == 'POST': # On met en vente une place if 'resell' in request.POST: resellform = ResellForm(participant, request.POST, prefix='resell') if resellform.is_valid(): datatuple = [] attributions = resellform.cleaned_data["attributions"] with transaction.atomic(): for attribution in attributions: revente, created = \ SpectacleRevente.objects.get_or_create( attribution=attribution, defaults={'seller': participant}) if not created: revente.seller = participant revente.date = timezone.now() revente.soldTo = None revente.notif_sent = False revente.tirage_done = False revente.shotgun = False context = { 'vendeur': participant.user, 'show': attribution.spectacle, 'revente': revente } datatuple.append(( 'bda-revente-new', context, settings.MAIL_DATA['revente']['FROM'], [participant.user.email] )) revente.save() send_mass_custom_mail(datatuple) # On annule une revente elif 'annul' in request.POST: annulform = AnnulForm(participant, request.POST, prefix='annul') if annulform.is_valid(): attributions = annulform.cleaned_data["attributions"] for attribution in attributions: attribution.revente.delete() # On confirme une vente en transférant la place à la personne qui a # gagné le tirage elif 'transfer' in request.POST: soldform = SoldForm(participant, request.POST, prefix='sold') if soldform.is_valid(): attributions = soldform.cleaned_data['attributions'] for attribution in attributions: attribution.participant = attribution.revente.soldTo attribution.save() # On annule la revente après le tirage au sort (par exemple si # la personne qui a gagné le tirage ne se manifeste pas). La place est # alors remise en vente elif 'reinit' in request.POST: soldform = SoldForm(participant, request.POST, prefix='sold') if soldform.is_valid(): attributions = soldform.cleaned_data['attributions'] for attribution in attributions: if attribution.spectacle.date > timezone.now(): revente = attribution.revente revente.date = timezone.now() - timedelta(minutes=65) revente.soldTo = None revente.notif_sent = False revente.tirage_done = False revente.shotgun = False if revente.answered_mail: revente.answered_mail.clear() revente.save() overdue = participant.attribution_set.filter( spectacle__date__gte=timezone.now(), revente__isnull=False, revente__seller=participant, revente__notif_sent=True)\ .filter( Q(revente__soldTo__isnull=True) | Q(revente__soldTo=participant)) return render(request, "bda/reventes.html", {'tirage': tirage, 'overdue': overdue, "soldform": soldform, "annulform": annulform, "resellform": resellform}) @login_required def revente_interested(request, revente_id): revente = get_object_or_404(SpectacleRevente, id=revente_id) participant, _ = Participant.objects.get_or_create( user=request.user, tirage=revente.attribution.spectacle.tirage) if (timezone.now() < revente.date + timedelta(hours=1)) or revente.shotgun: return render(request, "bda-wrongtime.html", {"revente": revente}) revente.answered_mail.add(participant) return render(request, "bda-interested.html", {"spectacle": revente.attribution.spectacle, "date": revente.date_tirage}) @login_required def list_revente(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) participant, _ = Participant.objects.get_or_create( user=request.user, tirage=tirage) deja_revente = False success = False inscrit_revente = [] if request.method == 'POST': form = InscriptionReventeForm(tirage, request.POST) if form.is_valid(): choices = form.cleaned_data['spectacles'] participant.choicesrevente = choices participant.save() for spectacle in choices: qset = SpectacleRevente.objects.filter( attribution__spectacle=spectacle) if qset.filter(shotgun=True, soldTo__isnull=True).exists(): # Une place est disponible au shotgun, on suggère à # l'utilisateur d'aller la récupérer deja_revente = True else: # La place n'est pas disponible au shotgun, si des reventes # pour ce spectacle existent déjà, on inscrit la personne à # la revente ayant le moins d'inscrits min_resell = ( qset.filter(shotgun=False) .annotate(nb_subscribers=Count('answered_mail')) .order_by('nb_subscribers') .first() ) if min_resell is not None: min_resell.answered_mail.add(participant) min_resell.save() inscrit_revente.append(spectacle) success = True else: form = InscriptionReventeForm( tirage, initial={'spectacles': participant.choicesrevente.all()} ) # Messages if success: messages.success(request, "Ton inscription a bien été prise en compte") if deja_revente: messages.info(request, "Des reventes existent déjà pour certains de " "ces spectacles, vérifie les places " "disponibles sans tirage !") if inscrit_revente: shows = map("