diff --git a/TODO_PROD.md b/TODO_PROD.md new file mode 100644 index 00000000..1a7d0736 --- /dev/null +++ b/TODO_PROD.md @@ -0,0 +1 @@ +- Changer les urls dans les mails "bda-revente" et "bda-shotgun" diff --git a/bda/admin.py b/bda/admin.py index 6638ad45..174f7878 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -236,7 +236,7 @@ class SpectacleReventeAdminForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['answered_mail'].queryset = ( + self.fields['confirmed_entry'].queryset = ( Participant.objects .select_related('user', 'tirage') ) @@ -299,13 +299,7 @@ class SpectacleReventeAdmin(admin.ModelAdmin): count = queryset.count() for revente in queryset.filter( attribution__spectacle__date__gte=timezone.now()): - revente.date = timezone.now() - timedelta(hours=1) - revente.soldTo = None - revente.notif_sent = False - revente.tirage_done = False - if revente.answered_mail: - revente.answered_mail.clear() - revente.save() + revente.reset(new_date=timezone.now() - timedelta(hours=1)) self.message_user( request, "%d attribution%s %s été réinitialisée%s avec succès." % ( diff --git a/bda/forms.py b/bda/forms.py index c0417d1e..90b0359f 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -4,7 +4,7 @@ from django import forms from django.forms.models import BaseInlineFormSet from django.utils import timezone -from bda.models import Attribution, Spectacle +from bda.models import Attribution, Spectacle, SpectacleRevente class InscriptionInlineFormSet(BaseInlineFormSet): @@ -43,7 +43,33 @@ class TokenForm(forms.Form): class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, obj): - return "%s" % str(obj.spectacle) + return str(obj.spectacle) + + +class ReventeModelMultipleChoiceField(forms.ModelMultipleChoiceField): + def __init__(self, *args, own=True, **kwargs): + super().__init__(*args, **kwargs) + self.own = own + + def label_from_instance(self, obj): + label = "{show}{suffix}" + suffix = "" + if self.own: + # C'est notre propre revente : pas besoin de spécifier le vendeur + if obj.soldTo is not None: + suffix = " -- Vendue à {firstname} {lastname}".format( + firstname=obj.soldTo.user.first_name, + lastname=obj.soldTo.user.last_name, + ) + else: + # Ce n'est pas à nous : on ne voit jamais l'acheteur + suffix = " -- Vendue par {firstname} {lastname}".format( + firstname=obj.seller.user.first_name, + lastname=obj.seller.user.last_name, + ) + + return label.format(show=str(obj.attribution.spectacle), + suffix=suffix) class ResellForm(forms.Form): @@ -65,7 +91,8 @@ class ResellForm(forms.Form): class AnnulForm(forms.Form): - attributions = AttributionModelMultipleChoiceField( + reventes = ReventeModelMultipleChoiceField( + own=True, label='', queryset=Attribution.objects.none(), widget=forms.CheckboxSelectMultiple, @@ -73,14 +100,13 @@ class AnnulForm(forms.Form): def __init__(self, participant, *args, **kwargs): super(AnnulForm, self).__init__(*args, **kwargs) - self.fields['attributions'].queryset = ( - participant.attribution_set - .filter(spectacle__date__gte=timezone.now(), - revente__isnull=False, - revente__notif_sent=False, - revente__soldTo__isnull=True) - .select_related('spectacle', 'spectacle__location', - 'participant__user') + self.fields['reventes'].queryset = ( + participant.original_shows + .filter(attribution__spectacle__date__gte=timezone.now(), + notif_sent=False, + soldTo__isnull=True) + .select_related('attribution__spectacle', + 'attribution__spectacle__location') ) @@ -99,19 +125,58 @@ class InscriptionReventeForm(forms.Form): ) +class ReventeTirageAnnulForm(forms.Form): + reventes = ReventeModelMultipleChoiceField( + own=False, + label='', + queryset=SpectacleRevente.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=False + ) + + def __init__(self, participant, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['reventes'].queryset = ( + participant.entered.filter(soldTo__isnull=True) + .select_related('attribution__spectacle', + 'seller__user') + ) + + +class ReventeTirageForm(forms.Form): + reventes = ReventeModelMultipleChoiceField( + own=False, + label='', + queryset=SpectacleRevente.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=False + ) + + def __init__(self, participant, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['reventes'].queryset = ( + SpectacleRevente.objects.filter( + notif_sent=True, + shotgun=False, + tirage_done=False + ).exclude(confirmed_entry=participant) + .select_related('attribution__spectacle') + ) + + class SoldForm(forms.Form): - attributions = AttributionModelMultipleChoiceField( + reventes = ReventeModelMultipleChoiceField( + own=True, label='', queryset=Attribution.objects.none(), widget=forms.CheckboxSelectMultiple) def __init__(self, participant, *args, **kwargs): super(SoldForm, self).__init__(*args, **kwargs) - self.fields['attributions'].queryset = ( - participant.attribution_set - .filter(revente__isnull=False, - revente__soldTo__isnull=False) - .exclude(revente__soldTo=participant) - .select_related('spectacle', 'spectacle__location', - 'participant__user') + self.fields['reventes'].queryset = ( + participant.original_shows + .filter(soldTo__isnull=False) + .exclude(soldTo=participant) + .select_related('attribution__spectacle', + 'attribution__spectacle__location') ) diff --git a/bda/management/commands/manage_reventes.py b/bda/management/commands/manage_reventes.py index 0302ec4b..5a767604 100644 --- a/bda/management/commands/manage_reventes.py +++ b/bda/management/commands/manage_reventes.py @@ -6,7 +6,6 @@ Gestion en ligne de commande des reventes. from __future__ import unicode_literals -from datetime import timedelta from django.core.management import BaseCommand from django.utils import timezone from bda.models import SpectacleRevente @@ -21,23 +20,36 @@ class Command(BaseCommand): now = timezone.now() reventes = SpectacleRevente.objects.all() for revente in reventes: - # Check si < 24h - if (revente.attribution.spectacle.date <= - revente.date + timedelta(days=1)) and \ - now >= revente.date + timedelta(minutes=15) and \ - not revente.notif_sent: - self.stdout.write(str(now)) - revente.mail_shotgun() - self.stdout.write("Mail de disponibilité immédiate envoyé") - # Check si délai de retrait dépassé - elif (now >= revente.date + timedelta(hours=1) and - not revente.notif_sent): + # Le spectacle est bientôt et on a pas encore envoyé de mail : + # on met la place au shotgun et on prévient. + if revente.is_urgent and not revente.notif_sent: + if revente.can_notif: + self.stdout.write(str(now)) + revente.mail_shotgun() + self.stdout.write( + "Mails de disponibilité immédiate envoyés " + "pour la revente [%s]" % revente + ) + + # Le spectacle est dans plus longtemps : on prévient + elif (revente.can_notif and not revente.notif_sent): self.stdout.write(str(now)) revente.send_notif() - self.stdout.write("Mail d'inscription à une revente envoyé") - # Check si tirage à faire - elif (now >= revente.date_tirage and - not revente.tirage_done): + self.stdout.write( + "Mails d'inscription à la revente [%s] envoyés" + % revente + ) + + # On fait le tirage + elif (now >= revente.date_tirage and not revente.tirage_done): self.stdout.write(str(now)) - revente.tirage() - self.stdout.write("Tirage effectué, mails envoyés") + winner = revente.tirage() + self.stdout.write( + "Tirage effectué pour la revente [%s]" + % revente + ) + + if winner: + self.stdout.write("Gagnant : %s" % winner.user) + else: + self.stdout.write("Pas de gagnant ; place au shotgun") diff --git a/bda/migrations/0012_notif_time.py b/bda/migrations/0012_notif_time.py new file mode 100644 index 00000000..ee777e35 --- /dev/null +++ b/bda/migrations/0012_notif_time.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bda', '0011_tirage_appear_catalogue'), + ] + + operations = [ + migrations.RenameField( + model_name='spectaclerevente', + old_name='answered_mail', + new_name='confirmed_entry', + ), + migrations.AlterField( + model_name='spectaclerevente', + name='confirmed_entry', + field=models.ManyToManyField(blank=True, related_name='entered', to='bda.Participant'), + ), + migrations.AddField( + model_name='spectaclerevente', + name='notif_time', + field=models.DateTimeField(blank=True, verbose_name="Moment d'envoi de la notification", null=True), + ), + ] diff --git a/bda/models.py b/bda/models.py index 73356038..f8735232 100644 --- a/bda/models.py +++ b/bda/models.py @@ -174,6 +174,7 @@ class Participant(models.Model): def __str__(self): return "%s - %s" % (self.user, self.tirage.title) + DOUBLE_CHOICES = ( ("1", "1 place"), ("autoquit", "2 places si possible, 1 sinon"), @@ -232,9 +233,9 @@ class SpectacleRevente(models.Model): ) date = models.DateTimeField("Date de mise en vente", default=timezone.now) - answered_mail = models.ManyToManyField(Participant, - related_name="wanted", - blank=True) + confirmed_entry = models.ManyToManyField(Participant, + related_name="entered", + blank=True) seller = models.ForeignKey( Participant, on_delete=models.CASCADE, verbose_name="Vendeur", @@ -248,21 +249,61 @@ class SpectacleRevente(models.Model): 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.""" - # L'acheteur doit être connu au plus 12h avant le spectacle + remaining_time = (self.attribution.spectacle.date - - self.date - timedelta(hours=13)) - # Au minimum, on attend 2 jours avant le tirage - delay = min(remaining_time, timedelta(days=2)) - # Le vendeur a aussi 1h pour changer d'avis - return self.date + delay + timedelta(hours=1) + - 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, @@ -271,6 +312,18 @@ class SpectacleRevente(models.Model): 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 @@ -291,6 +344,7 @@ class SpectacleRevente(models.Model): ] send_mass_custom_mail(datatuple) self.notif_sent = True + self.notif_time = timezone.now() self.save() def mail_shotgun(self): @@ -312,76 +366,79 @@ class SpectacleRevente(models.Model): ] send_mass_custom_mail(datatuple) 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): + 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.answered_mail.all()) + 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 = [] - mails = [] + context = { + 'acheteur': winner.user, + 'vendeur': seller.user, + 'show': spectacle, + } - context = { - 'acheteur': winner.user, - 'vendeur': seller.user, - 'show': spectacle, - } + c_mails_qs = CustomMail.objects.filter(shortname__in=[ + 'bda-revente-winner', 'bda-revente-loser', + 'bda-revente-seller', + ]) - c_mails_qs = CustomMail.objects.filter(shortname__in=[ - 'bda-revente-winner', 'bda-revente-loser', - 'bda-revente-seller', - ]) + c_mails = {cm.shortname: cm for cm in c_mails_qs} - c_mails = {cm.shortname: cm for cm in c_mails_qs} - - mails.append( - c_mails['bda-revente-winner'].get_message( - context, - from_email=settings.MAIL_DATA['revente']['FROM'], - to=[winner.user.email], - ) - ) - - mails.append( - c_mails['bda-revente-seller'].get_message( - 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( - c_mails['bda-revente-loser'].get_message( - new_context, - from_email=settings.MAIL_DATA['revente']['FROM'], - to=[inscrit.user.email], - ) + mails.append( + c_mails['bda-revente-winner'].get_message( + context, + from_email=settings.MAIL_DATA['revente']['FROM'], + to=[winner.user.email], ) + ) - mail_conn = mail.get_connection() - mail_conn.send_messages(mails) + mails.append( + c_mails['bda-revente-seller'].get_message( + 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( + c_mails['bda-revente-loser'].get_message( + 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 diff --git a/bda/templates/bda/liste-reventes.html b/bda/templates/bda/liste-reventes.html deleted file mode 100644 index fcf57345..00000000 --- a/bda/templates/bda/liste-reventes.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base_title.html" %} -{% load bootstrap %} - -{% block realcontent %} -

Inscriptions pour BdA-Revente

-
- {% csrf_token %} -
-

Spectacles

-
- - - -
-
    - {% for checkbox in form.spectacles %} -
  • {{checkbox}}
  • - {%endfor%} -
-
-
- -
- - -{% endblock %} diff --git a/bda/templates/revente-confirm.html b/bda/templates/bda/revente/confirm-shotgun.html similarity index 100% rename from bda/templates/revente-confirm.html rename to bda/templates/bda/revente/confirm-shotgun.html diff --git a/bda/templates/bda-interested.html b/bda/templates/bda/revente/confirmed.html similarity index 100% rename from bda/templates/bda-interested.html rename to bda/templates/bda/revente/confirmed.html diff --git a/bda/templates/bda-success.html b/bda/templates/bda/revente/mail-success.html similarity index 100% rename from bda/templates/bda-success.html rename to bda/templates/bda/revente/mail-success.html diff --git a/bda/templates/bda/revente/manage.html b/bda/templates/bda/revente/manage.html new file mode 100644 index 00000000..cf0ba80e --- /dev/null +++ b/bda/templates/bda/revente/manage.html @@ -0,0 +1,90 @@ +{% extends "base_title.html" %} +{% load bootstrap %} + +{% block realcontent %} + +

Gestion des places que je revends

+{% with resell_attributions=resellform.attributions annul_reventes=annulform.reventes sold_reventes=soldform.reventes %} + +{% if resellform.attributions %} +
+ +

Places non revendues

+
+
+ + Cochez les places que vous souhaitez revendre, et validez. Vous aurez + ensuite 1h pour changer d'avis avant que la revente soit confirmée et + que les notifications soient envoyées aux intéressé·e·s. +
+
+ {% csrf_token %} + {{ resellform|bootstrap }} +
+
+ +
+
+ +
+{% endif %} + +{% if annul_reventes or overdue %} +

Places en cours de revente

+
+ {% if annul_reventes %} +
+ + Vous pouvez annuler les places mises en vente il y a moins d'une heure. +
+ {% endif %} + {% csrf_token %} +
+
+
    + {% for revente in annul_reventes %} +
  • {{ revente.tag }} {{ revente.choice_label }}
  • + {% endfor %} + {% for attrib in overdue %} +
  • + + {{ attrib.spectacle }} +
  • + {% endfor %} +
+
+
+ {% if annul_reventes %} + + {% endif %} +
+ +
+{% endif %} + +{% if sold_reventes %} +

Places revendues

+
+
+ + Pour chaque revente, vous devez soit l'annuler soit la confirmer pour + transférer la place la place à la personne tirée au sort. + + L'annulation sert par exemple à pouvoir remettre la place en jeu si + vous ne parvenez pas à entrer en contact avec la personne tirée au + sort. +
+
+ {% csrf_token %} + {{ soldform|bootstrap }} +
+ + +
+{% endif %} +{% if not resell_attributions and not annul_attributions and not overdue and not sold_reventes %} +

Plus de reventes possibles !

+{% endif %} + +{% endwith %} +{% endblock %} diff --git a/bda/templates/bda-no-revente.html b/bda/templates/bda/revente/none.html similarity index 100% rename from bda/templates/bda-no-revente.html rename to bda/templates/bda/revente/none.html diff --git a/bda/templates/bda-notpaid.html b/bda/templates/bda/revente/notpaid.html similarity index 100% rename from bda/templates/bda-notpaid.html rename to bda/templates/bda/revente/notpaid.html diff --git a/bda/templates/bda-shotgun.html b/bda/templates/bda/revente/shotgun.html similarity index 83% rename from bda/templates/bda-shotgun.html rename to bda/templates/bda/revente/shotgun.html index e10fae00..fae36c04 100644 --- a/bda/templates/bda-shotgun.html +++ b/bda/templates/bda/revente/shotgun.html @@ -5,7 +5,7 @@ {% if shotgun %}