import hashlib import json import random import time from collections import defaultdict from custommail.models import CustomMail from custommail.shortcuts import send_custom_mail, send_mass_custom_mail from django.conf import settings from django.contrib import messages from django.core import serializers from django.core.exceptions import NON_FIELD_ERRORS from django.db import transaction from django.db.models import Count, Prefetch from django.forms.models import inlineformset_factory from django.http import HttpResponseBadRequest, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, render from django.template.defaultfilters import pluralize from django.urls import reverse from django.utils import formats, timezone from django.views.generic.list import ListView from bda.algorithm import Algorithm from bda.forms import ( AnnulForm, InscriptionInlineFormSet, InscriptionReventeForm, ResellForm, ReventeTirageAnnulForm, ReventeTirageForm, SoldForm, TokenForm, ) from bda.models import ( Attribution, CategorieSpectacle, ChoixSpectacle, Participant, Salle, Spectacle, SpectacleRevente, Tirage, ) from gestioncof.decorators import BuroRequiredMixin, buro_required, cof_required from utils.views.autocomplete import Select2QuerySetView @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", {}) participant, _ = Participant.objects.select_related("tirage").get_or_create( user=request.user, tirage=tirage ) if timezone.now() > tirage.fermeture: # Le tirage est fermé. 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} ) BdaFormSet = inlineformset_factory( Participant, ChoixSpectacle, fields=("spectacle", "double_choice", "priority"), formset=InscriptionInlineFormSet, error_messages={ NON_FIELD_ERRORS: { "unique_together": "Vous avez déjà demandé ce voeu plus haut !" } }, ) if request.method == "POST": # use *this* queryset dbstate = _hash_queryset(participant.choixspectacle_set.all()) if "dbstate" in request.POST and dbstate != request.POST["dbstate"]: formset = BdaFormSet(instance=participant) messages.error( request, "Impossible d'enregistrer vos modifications " ": vous avez apporté d'autres modifications " "entre temps.", ) else: formset = BdaFormSet(request.POST, instance=participant) if formset.is_valid(): formset.save() formset = BdaFormSet(instance=participant) messages.success( request, "Votre inscription a été mise à jour avec succès !" ) else: messages.error( request, "Une erreur s'est produite lors de l'enregistrement de vos vœux. " "Avez-vous demandé plusieurs fois le même spectacle ?", ) 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 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 # 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 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}) @cof_required def revente_manage(request, tirage_id): """ Gestion de ses propres reventes : - Création d'une revente - Annulation d'une revente - Confirmation d'une revente = transfert de la place à la personne qui rachète - Annulation d'une revente après que le tirage a eu lieu """ tirage = get_object_or_404(Tirage, id=tirage_id) participant, created = Participant.annotate_paid().get_or_create( user=request.user, tirage=tirage ) if not participant.paid: return render(request, "bda/revente/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.reset() 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(): reventes = annulform.cleaned_data["reventes"] for revente in reventes: 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(): reventes = soldform.cleaned_data["reventes"] for revente in reventes: revente.attribution.participant = revente.soldTo revente.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(): reventes = soldform.cleaned_data["reventes"] for revente in reventes: if revente.attribution.spectacle.date > timezone.now(): # On antidate pour envoyer le mail plus vite new_date = timezone.now() - SpectacleRevente.remorse_time revente.reset(new_date=new_date) sold_exists = soldform.fields["reventes"].queryset.exists() annul_exists = annulform.fields["reventes"].queryset.exists() resell_exists = resellform.fields["attributions"].queryset.exists() return render( request, "bda/revente/manage.html", { "tirage": tirage, "soldform": soldform, "annulform": annulform, "resellform": resellform, "sold_exists": sold_exists, "annul_exists": annul_exists, "resell_exists": resell_exists, }, ) @cof_required def revente_tirages(request, tirage_id): """ Affiche à un participant la liste de toutes les reventes en cours (pour un tirage donné) et lui permet de s'inscrire et se désinscrire à ces reventes. """ tirage = get_object_or_404(Tirage, id=tirage_id) participant, _ = Participant.objects.get_or_create(user=request.user, tirage=tirage) subform = ReventeTirageForm(participant, prefix="subscribe") annulform = ReventeTirageAnnulForm(participant, prefix="annul") if request.method == "POST": if "subscribe" in request.POST: subform = ReventeTirageForm(participant, request.POST, prefix="subscribe") if subform.is_valid(): reventes = subform.cleaned_data["reventes"] count = reventes.count() for revente in reventes: revente.confirmed_entry.add(participant) if count > 0: messages.success( request, "Tu as bien été inscrit à {} revente{}".format( count, pluralize(count) ), ) elif "annul" in request.POST: annulform = ReventeTirageAnnulForm( participant, request.POST, prefix="annul" ) if annulform.is_valid(): reventes = annulform.cleaned_data["reventes"] count = reventes.count() for revente in reventes: revente.confirmed_entry.remove(participant) if count > 0: messages.success( request, "Tu as bien été désinscrit de {} revente{}".format( count, pluralize(count) ), ) annul_exists = annulform.fields["reventes"].queryset.exists() sub_exists = subform.fields["reventes"].queryset.exists() return render( request, "bda/revente/tirages.html", { "annulform": annulform, "subform": subform, "annul_exists": annul_exists, "sub_exists": sub_exists, }, ) @cof_required def revente_confirm(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 not revente.notif_sent or revente.shotgun: return render(request, "bda/revente/wrongtime.html", {"revente": revente}) revente.confirmed_entry.add(participant) return render( request, "bda/revente/confirmed.html", {"spectacle": revente.attribution.spectacle, "date": revente.date_tirage}, ) @cof_required def revente_subscribe(request, tirage_id): """ Permet à un participant de sélectionner ses préférences pour les reventes. Il recevra des notifications pour les spectacles qui l'intéressent et il est automatiquement inscrit aux reventes en cours au moment où il ajoute un spectacle à la liste des spectacles qui l'intéressent. """ 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("confirmed_entry")) .order_by("nb_subscribers") .first() ) if min_resell is not None: min_resell.confirmed_entry.add(participant) inscrit_revente.append(spectacle) success = True else: form = InscriptionReventeForm( tirage, initial={"spectacles": participant.choicesrevente.all()} ) # Messages if success: messages.success(request, "Votre inscription a bien été prise en compte") if deja_revente: messages.info( request, "Des reventes existent déjà pour certains de " "ces spectacles, vérifiez les places " "disponibles sans tirage !", ) if inscrit_revente: shows = map("