import calendar import random from datetime import timedelta from django.conf import settings from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.core import mail from django.core.mail import EmailMessage, send_mass_mail from django.db import models from django.db.models import Count, Exists from django.template import loader from django.utils import formats, timezone def get_generic_user(): generic, _ = User.objects.get_or_create( username="bda_generic", defaults={"email": "bda@ens.fr", "first_name": "Bureau des arts"}, ) return generic class Tirage(models.Model): title = models.CharField("Titre", max_length=300) ouverture = models.DateTimeField("Date et heure d'ouverture du tirage") fermeture = models.DateTimeField("Date et heure de fermerture du tirage") tokens = models.TextField("Graine(s) du tirage", blank=True) active = models.BooleanField("Tirage actif", default=False) appear_catalogue = models.BooleanField( "Tirage à afficher dans le catalogue", default=False ) enable_do_tirage = models.BooleanField("Le tirage peut être lancé", default=False) archived = models.BooleanField("Archivé", default=False) def __str__(self): return "%s - %s" % ( self.title, formats.localize(timezone.template_localtime(self.fermeture)), ) class Salle(models.Model): name = models.CharField("Nom", max_length=300) address = models.TextField("Adresse") def __str__(self): return self.name class CategorieSpectacle(models.Model): name = models.CharField("Nom", max_length=100, unique=True) def __str__(self): return self.name class Meta: verbose_name = "Catégorie" class Spectacle(models.Model): title = models.CharField("Titre", max_length=300) category = models.ForeignKey( CategorieSpectacle, on_delete=models.CASCADE, blank=True, null=True ) date = models.DateTimeField("Date & heure") location = models.ForeignKey(Salle, on_delete=models.CASCADE) vips = models.TextField("Personnalités", blank=True) description = models.TextField("Description", blank=True) slots_description = models.TextField("Description des places", blank=True) image = models.ImageField("Image", blank=True, null=True, upload_to="imgs/shows/") ext_link = models.CharField( "Lien vers le site du spectacle", blank=True, max_length=500 ) price = models.FloatField("Prix d'une place") slots = models.IntegerField("Places") tirage = models.ForeignKey(Tirage, on_delete=models.CASCADE) listing = models.BooleanField("Les places sont sur listing") rappel_sent = models.DateTimeField("Mail de rappel envoyé", blank=True, null=True) class Meta: verbose_name = "Spectacle" ordering = ("date", "title") def timestamp(self): return "%d" % calendar.timegm(self.date.utctimetuple()) def __str__(self): return "%s - %s, %s, %.02f€" % ( self.title, formats.localize(timezone.template_localtime(self.date)), self.location, self.price, ) def getImgUrl(self): """ Cette fonction permet d'obtenir l'URL de l'image, si elle existe """ try: return self.image.url except Exception: return None def send_rappel(self): """ Envoie un mail de rappel à toutes les personnes qui ont une place pour ce spectacle. """ # On récupère la liste des participants + le BdA members = list( User.objects.filter(participant__attributions=self) .annotate(nb_attr=Count("id")) .order_by() ) bda_generic = get_generic_user() bda_generic.nb_attr = 1 members.append(bda_generic) # On écrit un mail personnalisé à chaque participant mails = [ ( str(self), loader.render_to_string( "bda/mails/rappel.txt", context={"member": member, "nb_attr": member.nb_attr, "show": self}, ), settings.MAIL_DATA["rappels"]["FROM"], [member.email], ) for member in members ] send_mass_mail(mails) # On enregistre le fait que l'envoi a bien eu lieu self.rappel_sent = timezone.now() self.save() # On renvoie la liste des destinataires return members @property def is_past(self): return self.date < timezone.now() class Quote(models.Model): spectacle = models.ForeignKey(Spectacle, on_delete=models.CASCADE) text = models.TextField("Citation") author = models.CharField("Auteur", max_length=200) PAYMENT_TYPES = ( ("cash", "Cash"), ("cb", "CB"), ("cheque", "Chèque"), ("autre", "Autre"), ) class Attribution(models.Model): participant = models.ForeignKey("Participant", on_delete=models.CASCADE) spectacle = models.ForeignKey( Spectacle, on_delete=models.CASCADE, related_name="attribues" ) given = models.BooleanField("Donnée", default=False) paid = models.BooleanField("Payée", default=False) paymenttype = models.CharField( "Moyen de paiement", max_length=6, choices=PAYMENT_TYPES, blank=True ) def __str__(self): return "%s -- %s, %s" % ( self.participant.user, self.spectacle.title, self.spectacle.date, ) class ParticipantPaidQueryset(models.QuerySet): """ Un manager qui annote le queryset avec un champ `paid`, indiquant si un participant a payé toutes ses attributions. """ def annotate_paid(self): # OuterRef permet de se référer à un champ d'un modèle non encore fixé # Voir: # https://docs.djangoproject.com/en/2.2/ref/models/expressions/#django.db.models.OuterRef unpaid = Attribution.objects.filter( participant=models.OuterRef("pk"), paid=False ) return self.annotate(paid=~Exists(unpaid)) class Participant(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) choices = models.ManyToManyField( Spectacle, through="ChoixSpectacle", related_name="chosen_by" ) attributions = models.ManyToManyField( Spectacle, through="Attribution", related_name="attributed_to" ) tirage = models.ForeignKey( Tirage, on_delete=models.CASCADE, limit_choices_to={"archived": False} ) accepte_charte = models.BooleanField("A accepté la charte BdA", default=False) choicesrevente = models.ManyToManyField( Spectacle, related_name="subscribed", blank=True ) objects = ParticipantPaidQueryset.as_manager() def __str__(self): return "%s - %s" % (self.user, self.tirage.title) class Meta: ordering = ("-tirage", "user__last_name", "user__first_name") constraints = [ models.UniqueConstraint(fields=("tirage", "user"), name="unique_tirage"), ] DOUBLE_CHOICES = ( ("1", "1 place"), ("double", "2 places si possible, 1 sinon"), ("autoquit", "2 places sinon rien"), ) class ChoixSpectacle(models.Model): participant = models.ForeignKey(Participant, on_delete=models.CASCADE) spectacle = models.ForeignKey( Spectacle, on_delete=models.CASCADE, related_name="participants" ) priority = models.PositiveIntegerField("Priorité") double_choice = models.CharField( "Nombre de places", default="1", choices=DOUBLE_CHOICES, max_length=10 ) def get_double(self): return self.double_choice != "1" double = property(get_double) def get_autoquit(self): return self.double_choice == "autoquit" autoquit = property(get_autoquit) def __str__(self): return "Vœux de %s pour %s" % ( self.participant.user.get_full_name(), self.spectacle.title, ) class Meta: ordering = ("priority",) unique_together = (("participant", "spectacle"),) verbose_name = "voeu" verbose_name_plural = "voeux" class SpectacleRevente(models.Model): attribution = models.OneToOneField( Attribution, on_delete=models.CASCADE, related_name="revente" ) date = models.DateTimeField("Date de mise en vente", default=timezone.now) confirmed_entry = models.ManyToManyField( Participant, related_name="entered", blank=True ) seller = models.ForeignKey( Participant, on_delete=models.CASCADE, verbose_name="Vendeur", related_name="original_shows", ) soldTo = models.ForeignKey( Participant, on_delete=models.CASCADE, verbose_name="Vendue à", blank=True, null=True, ) notif_sent = models.BooleanField("Notification envoyée", default=False) notif_time = models.DateTimeField( "Moment d'envoi de la notification", blank=True, null=True ) tirage_done = models.BooleanField("Tirage effectué", default=False) shotgun = models.BooleanField("Disponible immédiatement", default=False) #### # Some class attributes ### # TODO : settings ? # Temps minimum entre le tirage et le spectacle min_margin = timedelta(days=5) # Temps entre la création d'une revente et l'envoi du mail remorse_time = timedelta(hours=1) # Temps min/max d'attente avant le tirage max_wait_time = timedelta(days=3) min_wait_time = timedelta(days=1) @property def real_notif_time(self): if self.notif_time: return self.notif_time else: return self.date + self.remorse_time @property def date_tirage(self): """Renvoie la date du tirage au sort de la revente.""" remaining_time = ( self.attribution.spectacle.date - self.real_notif_time - self.min_margin ) delay = min(remaining_time, self.max_wait_time) return self.real_notif_time + delay @property def is_urgent(self): """ Renvoie True iff la revente doit être mise au shotgun directement. Plus précisément, on doit avoir min_margin + min_wait_time de marge. """ spectacle_date = self.attribution.spectacle.date return spectacle_date <= timezone.now() + self.min_margin + self.min_wait_time @property def can_notif(self): return timezone.now() >= self.date + self.remorse_time def __str__(self): return "%s -- %s" % (self.seller, self.attribution.spectacle.title) class Meta: verbose_name = "Revente" def reset(self, new_date=timezone.now()): """Réinitialise la revente pour permettre une remise sur le marché""" self.seller = self.attribution.participant self.date = new_date self.confirmed_entry.clear() self.soldTo = None self.notif_sent = False self.notif_time = None self.tirage_done = False self.shotgun = False self.save() def send_notif(self): """ Envoie une notification pour indiquer la mise en vente d'une place sur BdA-Revente à tous les intéressés. """ inscrits = self.attribution.spectacle.subscribed.select_related("user") mails = [ ( "BdA-Revente : {}".format(self.attribution.spectacle.title), loader.render_to_string( "bda/mails/revente-new.txt", context={ "member": participant.user, "show": self.attribution.spectacle, "revente": self, "site": Site.objects.get_current(), }, ), settings.MAIL_DATA["revente"]["FROM"], [participant.user.email], ) for participant in inscrits ] send_mass_mail(mails) self.notif_sent = True self.notif_time = timezone.now() self.save() def mail_shotgun(self): """ Envoie un mail à toutes les personnes intéréssées par le spectacle pour leur indiquer qu'il est désormais disponible au shotgun. """ inscrits = self.attribution.spectacle.subscribed.select_related("user") mails = [ ( "BdA-Revente : {}".format(self.attribution.spectacle.title), loader.render_to_string( "bda/mails/revente-shotgun.txt", context={ "member": participant.user, "show": self.attribution.spectacle, "site": Site.objects.get_current(), }, ), settings.MAIL_DATA["revente"]["FROM"], [participant.user.email], ) for participant in inscrits ] send_mass_mail(mails) self.notif_sent = True self.notif_time = timezone.now() # Flag inutile, sauf si l'horloge interne merde self.tirage_done = True self.shotgun = True self.save() def tirage(self, send_mails=True): """ Lance le tirage au sort associé à la revente. Un gagnant est choisi parmis les personnes intéressées par le spectacle. Les personnes sont ensuites prévenues par mail du résultat du tirage. """ inscrits = list(self.confirmed_entry.all()) spectacle = self.attribution.spectacle seller = self.seller winner = None if inscrits: # Envoie un mail au gagnant et au vendeur winner = random.choice(inscrits) self.soldTo = winner if send_mails: mails = [] context = { "acheteur": winner.user, "vendeur": seller.user, "show": spectacle, } subject = "BdA-Revente : {}".format(spectacle.title) mails.append( EmailMessage( subject=subject, body=loader.render_to_string( "bda/mails/revente-tirage-winner.txt", context=context, ), from_email=settings.MAIL_DATA["revente"]["FROM"], to=[winner.user.email], ) ) mails.append( EmailMessage( subject=subject, body=loader.render_to_string( "bda/mails/revente-tirage-seller.txt", context=context, ), from_email=settings.MAIL_DATA["revente"]["FROM"], to=[seller.user.email], reply_to=[winner.user.email], ), ) # Envoie un mail aux perdants for inscrit in inscrits: if inscrit != winner: new_context = dict(context) new_context["acheteur"] = inscrit.user mails.append( EmailMessage( subject=subject, body=loader.render_to_string( "bda/mails/revente-tirage-loser.txt", context=new_context, ), from_email=settings.MAIL_DATA["revente"]["FROM"], to=[inscrit.user.email], ), ) mail_conn = mail.get_connection() mail_conn.send_messages(mails) # Si personne ne veut de la place, elle part au shotgun else: self.shotgun = True self.tirage_done = True self.save() return winner