import random import hashlib import time import json from collections import defaultdict from custommail.shortcuts import send_mass_custom_mail, send_custom_mail from custommail.models import CustomMail from datetime import timedelta from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core import serializers from django.core.urlresolvers import reverse from django.db import transaction from django.db.models import Count, Q, Prefetch from django.forms.models import inlineformset_factory from django.http import ( HttpResponseBadRequest, HttpResponseRedirect, JsonResponse ) from django.shortcuts import render, get_object_or_404 from django.utils import timezone, formats from django.views.generic.list import ListView from cof.decorators import cof_required, buro_required from .models import ( Attribution, CategorieSpectacle, ChoixSpectacle, Participant, Salle, Spectacle, SpectacleRevente, Tirage ) from .algorithm import Algorithm from .forms import ( TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, SoldForm, InscriptionInlineFormSet, ) @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, ) 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 # 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}) @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) 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("
  • {!s}
  • ".format, inscrit_revente) msg = ( "Tu as été inscrit à des reventes en cours pour les spectacles " "".format('\n'.join(shows)) ) messages.info(request, msg, extra_tags="safe") return render(request, "bda/liste-reventes.html", {"form": form}) @login_required def buy_revente(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-shotgun", args=[tirage.id])) reventes_shotgun = reventes.filter(shotgun=True) if not reventes_shotgun: return render(request, "bda-no-revente.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-success.html", {"seller": revente.attribution.participant.user, "spectacle": spectacle}) return render(request, "revente-confirm.html", {"spectacle": spectacle, "user": request.user}) @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()) .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}) @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(SpectacleListView, self).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 descriptions_spectacles(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) 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: shows_qs = shows_qs.filter(category__name=category_name) if location_id: try: shows_qs = shows_qs.filter(location__id=int(location_id)) except ValueError: return HttpResponseBadRequest( "La variable GET 'location' doit contenir un entier") return render(request, 'descriptions.html', {'shows': shows_qs}) 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()