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 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.objects.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("
  • {!s}
  • ".format, inscrit_revente) msg = ( "Vous avez été inscrit·e à des reventes en cours pour les spectacles " "".format("\n".join(shows)) ) messages.info(request, msg, extra_tags="safe") return render(request, "bda/revente/subscribe.html", {"form": form}) @cof_required def revente_buy(request, spectacle_id): spectacle = get_object_or_404(Spectacle, id=spectacle_id) tirage = spectacle.tirage participant, _ = Participant.objects.get_or_create(user=request.user, tirage=tirage) reventes = SpectacleRevente.objects.filter( attribution__spectacle=spectacle, soldTo__isnull=True ) # Si l'utilisateur veut racheter une place qu'il est en train de revendre, # on supprime la revente en question. own_reventes = reventes.filter(seller=participant) if len(own_reventes) > 0: own_reventes[0].delete() return HttpResponseRedirect(reverse("bda-revente-shotgun", args=[tirage.id])) reventes_shotgun = reventes.filter(shotgun=True) if not reventes_shotgun: return render(request, "bda/revente/none.html", {}) if request.POST: revente = random.choice(reventes_shotgun) revente.soldTo = participant revente.save() context = { "show": spectacle, "acheteur": request.user, "vendeur": revente.seller.user, } send_custom_mail( "bda-buy-shotgun", "bda@ens.fr", [revente.seller.user.email], context=context, ) return render( request, "bda/revente/mail-success.html", {"seller": revente.attribution.participant.user, "spectacle": spectacle}, ) return render( request, "bda/revente/confirm-shotgun.html", {"spectacle": spectacle, "user": request.user}, ) @cof_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()) .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/revente/shotgun.html", {"spectacles": shotgun}) @buro_required 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.select_related( "participant", "participant__user" ) participants = {} for attrib in attributions: participant = attrib.participant participant_info = { "lastname": participant.user.last_name, "name": participant.user.get_full_name, "username": participant.user.username, "email": participant.user.email, "given": int(attrib.given), "paid": participant.paid, "nb_places": 1, } if participant.id in participants: participants[participant.id]["nb_places"] += 1 participants[participant.id]["given"] += attrib.given else: participants[participant.id] = participant_info participants_info = sorted(participants.values(), key=lambda part: part["lastname"]) return render( request, "bda/participants.html", {"spectacle": spectacle, "participants": participants_info}, ) class SpectacleListView(ListView): model = Spectacle template_name = "spectacle_list.html" def get_queryset(self): self.tirage = get_object_or_404(Tirage, id=self.kwargs["tirage_id"]) categories = self.tirage.spectacle_set.select_related("location") return categories def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["tirage_id"] = self.tirage.id context["tirage_name"] = self.tirage.title return context @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) .select_related("user") ) return render(request, "bda-unpaid.html", {"unpaid": unpaid}) @buro_required def send_rappel(request, spectacle_id): show = get_object_or_404(Spectacle, id=spectacle_id) # Mails d'exemples custommail = CustomMail.objects.get(shortname="bda-rappel") exemple_mail_1place = custommail.render( {"member": request.user, "show": show, "nb_attr": 1} ) exemple_mail_2places = custommail.render( {"member": request.user, "show": show, "nb_attr": 2} ) # Contexte ctxt = { "show": show, "exemple_mail_1place": exemple_mail_1place, "exemple_mail_2places": exemple_mail_2places, "custommail": custommail, } # Envoi confirmé if request.method == "POST": members = show.send_rappel() ctxt["sent"] = True ctxt["members"] = members # Demande de confirmation else: ctxt["sent"] = False if show.rappel_sent: messages.warning( request, "Attention, un mail de rappel pour ce spectale a déjà été " "envoyé le {}".format( formats.localize(timezone.template_localtime(show.rappel_sent)) ), ) return render(request, "bda/mails-rappel.html", ctxt) def catalogue(request, request_type): """ Vue destinée à communiquer avec un client AJAX, fournissant soit : - la liste des tirages - les catégories et salles d'un tirage - les descriptions d'un tirage (filtrées selon la catégorie et la salle) """ if request_type == "list": # Dans ce cas on retourne la liste des tirages et de leur id en JSON data_return = list( Tirage.objects.filter(appear_catalogue=True).values("id", "title") ) return JsonResponse(data_return, safe=False) if request_type == "details": # Dans ce cas on retourne une liste des catégories et des salles tirage_id = request.GET.get("id", None) if tirage_id is None: return HttpResponseBadRequest("Missing GET parameter: id ") try: tirage = get_object_or_404(Tirage, id=int(tirage_id)) except ValueError: return HttpResponseBadRequest("Bad format: int expected for `id`") shows = tirage.spectacle_set.values_list("id", flat=True) categories = list( CategorieSpectacle.objects.filter(spectacle__in=shows) .distinct() .values("id", "name") ) locations = list( Salle.objects.filter(spectacle__in=shows).distinct().values("id", "name") ) data_return = {"categories": categories, "locations": locations} return JsonResponse(data_return, safe=False) if request_type == "descriptions": # Ici on retourne les descriptions correspondant à la catégorie et # à la salle spécifiées tirage_id = request.GET.get("id", "") categories = request.GET.get("category", "[]") locations = request.GET.get("location", "[]") try: tirage_id = int(tirage_id) categories_id = json.loads(categories) locations_id = json.loads(locations) # Integers expected if not all(isinstance(id, int) for id in categories_id): raise ValueError if not all(isinstance(id, int) for id in locations_id): raise ValueError except ValueError: # Contient JSONDecodeError return HttpResponseBadRequest( "Parse error, please ensure the GET parameters have the " "following types:\n" "id: int, category: [int], location: [int]\n" "Data received:\n" "id = {}, category = {}, locations = {}".format( request.GET.get("id", ""), request.GET.get("category", "[]"), request.GET.get("location", "[]"), ) ) tirage = get_object_or_404(Tirage, id=tirage_id) shows_qs = tirage.spectacle_set.select_related("location").prefetch_related( "quote_set" ) if categories_id and 0 not in categories_id: shows_qs = shows_qs.filter(category__id__in=categories_id) if locations_id and 0 not in locations_id: shows_qs = shows_qs.filter(location__id__in=locations_id) # On convertit les descriptions à envoyer en une liste facilement # JSONifiable (il devrait y avoir un moyen plus efficace en # redéfinissant le serializer de JSON) data_return = [ { "title": spectacle.title, "category": str(spectacle.category), "date": str( formats.date_format( timezone.localtime(spectacle.date), "SHORT_DATETIME_FORMAT" ) ), "location": str(spectacle.location), "vips": spectacle.vips, "description": spectacle.description, "slots_description": spectacle.slots_description, "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 ] return JsonResponse(data_return, safe=False) # Si la requête n'est pas de la forme attendue, on quitte avec une erreur return HttpResponseBadRequest() ## # Autocomplete views # # https://django-autocomplete-light.readthedocs.io/en/master/tutorial.html#create-an-autocomplete-view ## class ParticipantAutocomplete(Select2QuerySetView): model = Participant search_fields = ("user__username", "user__first_name", "user__last_name") participant_autocomplete = buro_required(ParticipantAutocomplete.as_view()) class SpectacleAutocomplete(Select2QuerySetView): model = Spectacle search_fields = ("title",) spectacle_autocomplete = buro_required(SpectacleAutocomplete.as_view())