Compare commits

..

6 commits

430 changed files with 24271 additions and 1236 deletions

1
.envrc
View file

@ -1 +0,0 @@
use nix

4
.gitignore vendored
View file

@ -5,7 +5,6 @@ cof/settings.py
settings.py settings.py
*~ *~
venv/ venv/
.venv/
.vagrant .vagrant
/src /src
media/ media/
@ -19,5 +18,4 @@ media/
.cache .cache
# VSCode # VSCode
.vscode/ .vscode/
.direnv

View file

@ -21,66 +21,10 @@ Liste des changements notables dans GestioCOF depuis la version 0.1 (septembre
Uniquement un modèle simple de clubs avec des respos. Aucune gestion des Uniquement un modèle simple de clubs avec des respos. Aucune gestion des
adhérents ni des cotisations. adhérents ni des cotisations.
## TODO Prod ## Version 0.11
- Créer un compte hCaptcha (https://www.hcaptcha.com/), au COF, et remplacer les secrets associés
## Version ??? - ??/??/????
## Version 0.15.1 - 15/06/2023
### K-Fêt ### K-Fêt
- Rattrape les erreurs d'envoi de mail de négatif
- Utilise l'adresse chefs pour les envois de négatifs
## Version 0.15 - 22/05/2023
### K-Fêt
- Rajoute un formulaire de contact
- Rajoute un formulaire de demande de soirée
- Désactive les mails d'envoi de négatifs sur les comptes gelés
## Version 0.14 - 19/05/2023
- Répare les dépendances en spécifiant toutes les versions
### K-Fêt
- Répare la gestion des changement d'heure via moment.js
## Version 0.13 - 19/02/2023
### K-Fêt
- Rajoute la valeur des inventaires
- Résout les problèmes de négatif ne disparaissant pas
- Affiche son surnom s'il y en a un
- Bugfixes
## Version 0.12.1 - 03/10/2022
### K-Fêt
- Fixe un problème de rendu causé par l'agrandissement du menu
## Version 0.12 - 17/06/2022
### K-Fêt
- Ajoute une exception à la limite d'historique pour les comptes `LIQ` et `#13`
- Répare le problème des étiquettes LIQ/Comptes K-Fêt inversées dans les stats des articles K-Fêt
## Version 0.11 - 26/10/2021
### COF
- Répare un problème de rendu sur le wagtail du COF
### K-Fêt
- Ajoute de mails de rappels pour les comptes en négatif
- La recherche de comptes sur K-Psul remarche normalement - La recherche de comptes sur K-Psul remarche normalement
- Le pointeur de la souris change de forme quand on survole un item d'autocomplétion - Le pointeur de la souris change de forme quand on survole un item d'autocomplétion
- Modification du gel de compte: - Modification du gel de compte:
@ -97,6 +41,10 @@ adhérents ni des cotisations.
## Version 0.10 - 18/04/2021 ## Version 0.10 - 18/04/2021
### TODO Prod
- Créer un compte hCaptcha (https://www.hcaptcha.com/), au COF, et remplacer les secrets associés
### K-Fêt ### K-Fêt
- On fait sauter la limite qui empêchait de vendre plus de 24 unités d'un item à - On fait sauter la limite qui empêchait de vendre plus de 24 unités d'un item à

0
bda/__init__.py Normal file
View file

351
bda/admin.py Normal file
View file

@ -0,0 +1,351 @@
from datetime import timedelta
from dal.autocomplete import ModelSelect2
from django import forms
from django.contrib import admin
from django.core.mail import send_mass_mail
from django.db.models import Count, Q, Sum
from django.template import loader
from django.template.defaultfilters import pluralize
from django.utils import timezone
from bda.models import (
Attribution,
CategorieSpectacle,
ChoixSpectacle,
Participant,
Quote,
Salle,
Spectacle,
SpectacleRevente,
Tirage,
)
class ReadOnlyMixin(object):
readonly_fields_update = ()
def get_readonly_fields(self, request, obj=None):
readonly_fields = super().get_readonly_fields(request, obj)
if obj is None:
return readonly_fields
else:
return readonly_fields + self.readonly_fields_update
class AttributionTabularAdminForm(forms.ModelForm):
listing = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
spectacles = Spectacle.objects.select_related("location")
if self.listing is not None:
spectacles = spectacles.filter(listing=self.listing)
self.fields["spectacle"].queryset = spectacles
class WithoutListingAttributionTabularAdminForm(AttributionTabularAdminForm):
listing = False
class WithListingAttributionTabularAdminForm(AttributionTabularAdminForm):
listing = True
class AttributionInline(admin.TabularInline):
model = Attribution
extra = 0
listing = None
def get_queryset(self, request):
qs = super().get_queryset(request)
if self.listing is not None:
qs = qs.filter(spectacle__listing=self.listing)
return qs
class WithListingAttributionInline(AttributionInline):
exclude = ("given",)
form = WithListingAttributionTabularAdminForm
listing = True
verbose_name_plural = "Attributions sur listing"
class WithoutListingAttributionInline(AttributionInline):
form = WithoutListingAttributionTabularAdminForm
listing = False
verbose_name_plural = "Attributions hors listing"
class ParticipantAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
queryset = Spectacle.objects.select_related("location")
if self.instance.pk is not None:
queryset = queryset.filter(tirage=self.instance.tirage)
self.fields["choicesrevente"].queryset = queryset
class ParticipantPaidFilter(admin.SimpleListFilter):
"""
Permet de filtrer les participants sur s'ils ont payé leurs places ou pas
"""
title = "A payé"
parameter_name = "paid"
def lookups(self, request, model_admin):
return ((True, "Oui"), (False, "Non"))
def queryset(self, request, queryset):
return queryset.filter(paid=self.value())
class ParticipantAdmin(ReadOnlyMixin, admin.ModelAdmin):
inlines = [WithListingAttributionInline, WithoutListingAttributionInline]
def get_queryset(self, request):
return self.model.objects.annotate_paid().annotate(
nb_places=Count("attributions"),
remain=Sum(
"attribution__spectacle__price", filter=Q(attribution__paid=False)
),
total=Sum("attributions__price"),
)
def nb_places(self, obj):
return obj.nb_places
nb_places.admin_order_field = "nb_places"
nb_places.short_description = "Nombre de places"
def paid(self, obj):
return obj.paid
paid.short_description = "A payé"
paid.boolean = True
paid.admin_order_field = "paid"
def total(self, obj):
tot = obj.total
if tot:
return "%.02f" % tot
else:
return "0 €"
total.admin_order_field = "total"
total.short_description = "Total des places"
def remain(self, obj):
rem = obj.remain
if rem:
return "%.02f" % rem
else:
return "0 €"
remain.admin_order_field = "remain"
remain.short_description = "Reste à payer"
list_display = ("user", "nb_places", "total", "paid", "remain", "tirage")
list_filter = (ParticipantPaidFilter, "tirage")
search_fields = ("user__username", "user__first_name", "user__last_name")
actions = ["send_attribs"]
actions_on_bottom = True
list_per_page = 400
readonly_fields = ("total", "paid")
readonly_fields_update = ("user", "tirage")
form = ParticipantAdminForm
def send_attribs(self, request, queryset):
emails = []
for member in queryset.all():
subject = "Résultats du tirage au sort"
attribs = member.attributions.all()
context = {"member": member.user}
template_name = ""
if len(attribs) == 0:
template_name = "bda/mails/attributions-decus.txt"
else:
template_name = "bda/mails/attributions.txt"
context["places"] = attribs
message = loader.render_to_string(template_name, context)
emails.append((subject, message, "bda@ens.fr", [member.user.email]))
send_mass_mail(emails)
count = len(queryset.all())
if count == 1:
message_bit = "1 membre a"
plural = ""
else:
message_bit = "%d membres ont" % count
plural = "s"
self.message_user(
request, "%s été informé%s avec succès." % (message_bit, plural)
)
send_attribs.short_description = "Envoyer les résultats par mail"
class AttributionAdminForm(forms.ModelForm):
def clean(self):
cleaned_data = super().clean()
participant = cleaned_data.get("participant")
spectacle = cleaned_data.get("spectacle")
if participant and spectacle:
if participant.tirage != spectacle.tirage:
raise forms.ValidationError(
"Erreur : le participant et le spectacle n'appartiennent"
"pas au même tirage"
)
return cleaned_data
class Meta:
widgets = {
"participant": ModelSelect2(url="bda-participant-autocomplete"),
"spectacle": ModelSelect2(url="bda-spectacle-autocomplete"),
}
class AttributionAdmin(ReadOnlyMixin, admin.ModelAdmin):
list_display = ("id", "spectacle", "participant", "given", "paid")
search_fields = (
"spectacle__title",
"participant__user__username",
"participant__user__first_name",
"participant__user__last_name",
)
form = AttributionAdminForm
readonly_fields_update = ("spectacle", "participant")
class ChoixSpectacleAdmin(admin.ModelAdmin):
autocomplete_fields = ["participant", "spectacle"]
def tirage(self, obj):
return obj.participant.tirage
list_display = ("participant", "tirage", "spectacle", "priority", "double_choice")
list_filter = ("double_choice", "participant__tirage")
search_fields = (
"participant__user__username",
"participant__user__first_name",
"participant__user__last_name",
"spectacle__title",
)
class QuoteInline(admin.TabularInline):
model = Quote
class SpectacleAdmin(admin.ModelAdmin):
inlines = [QuoteInline]
model = Spectacle
list_display = ("title", "date", "tirage", "location", "slots", "price", "listing")
list_filter = ("location", "tirage")
search_fields = ("title", "location__name")
readonly_fields = ("rappel_sent",)
class TirageAdmin(admin.ModelAdmin):
model = Tirage
list_display = ("title", "ouverture", "fermeture", "active", "enable_do_tirage")
readonly_fields = ("tokens",)
list_filter = ("active",)
search_fields = ("title",)
class SalleAdmin(admin.ModelAdmin):
model = Salle
search_fields = ("name", "address")
class SpectacleReventeAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
qset = Participant.objects.select_related("user", "tirage")
if self.instance.pk is not None:
qset = qset.filter(tirage=self.instance.seller.tirage)
self.fields["confirmed_entry"].queryset = qset
self.fields["seller"].queryset = qset
self.fields["soldTo"].queryset = qset
class SpectacleReventeAdmin(admin.ModelAdmin):
"""
Administration des reventes de spectacles
"""
model = SpectacleRevente
def spectacle(self, obj):
"""
Raccourci vers le spectacle associé à la revente.
"""
return obj.attribution.spectacle
list_display = ("spectacle", "seller", "date", "soldTo")
raw_id_fields = ("attribution",)
readonly_fields = ("date_tirage",)
search_fields = [
"attribution__spectacle__title",
"seller__user__username",
"seller__user__first_name",
"seller__user__last_name",
]
actions = ["transfer", "reinit"]
actions_on_bottom = True
form = SpectacleReventeAdminForm
def transfer(self, request, queryset):
"""
Effectue le transfert des reventes pour lesquels on connaît l'acheteur.
"""
reventes = queryset.exclude(soldTo__isnull=True).all()
count = reventes.count()
for revente in reventes:
attrib = revente.attribution
attrib.participant = revente.soldTo
attrib.save()
self.message_user(
request,
"%d attribution%s %s été transférée%s avec succès."
% (count, pluralize(count), pluralize(count, "a,ont"), pluralize(count)),
)
transfer.short_description = "Transférer les reventes sélectionnées"
def reinit(self, request, queryset):
"""
Réinitialise les reventes.
"""
count = queryset.count()
for revente in queryset.filter(
attribution__spectacle__date__gte=timezone.now()
):
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."
% (count, pluralize(count), pluralize(count, "a,ont"), pluralize(count)),
)
reinit.short_description = "Réinitialiser les reventes sélectionnées"
admin.site.register(CategorieSpectacle)
admin.site.register(Spectacle, SpectacleAdmin)
admin.site.register(Salle, SalleAdmin)
admin.site.register(Participant, ParticipantAdmin)
admin.site.register(Attribution, AttributionAdmin)
admin.site.register(ChoixSpectacle, ChoixSpectacleAdmin)
admin.site.register(Tirage, TirageAdmin)
admin.site.register(SpectacleRevente, SpectacleReventeAdmin)

103
bda/algorithm.py Normal file
View file

@ -0,0 +1,103 @@
import random
class Algorithm(object):
shows = None
ranks = None
origranks = None
double = None
def __init__(self, shows, members, choices):
"""Initialisation :
- on aggrège toutes les demandes pour chaque spectacle dans
show.requests
- on crée des tables de demandes pour chaque personne, afin de
pouvoir modifier les rankings"""
self.max_group = 2 * max(choice.priority for choice in choices)
self.shows = []
showdict = {}
for show in shows:
show.nrequests = 0
showdict[show] = show
show.requests = []
self.shows.append(show)
self.ranks = {}
self.origranks = {}
self.choices = {}
next_rank = {}
member_shows = {}
for member in members:
self.ranks[member] = {}
self.choices[member] = {}
next_rank[member] = 1
member_shows[member] = {}
for choice in choices:
member = choice.participant
if choice.spectacle in member_shows[member]:
continue
else:
member_shows[member][choice.spectacle] = True
showdict[choice.spectacle].requests.append(member)
showdict[choice.spectacle].nrequests += 2 if choice.double else 1
self.ranks[member][choice.spectacle] = next_rank[member]
next_rank[member] += 2 if choice.double else 1
self.choices[member][choice.spectacle] = choice
for member in members:
self.origranks[member] = dict(self.ranks[member])
def IncrementRanks(self, member, currank, increment=1):
for show in self.ranks[member]:
if self.ranks[member][show] > currank:
self.ranks[member][show] -= increment
def appendResult(self, l, member, show):
l.append(
(
member,
self.ranks[member][show],
self.origranks[member][show],
self.choices[member][show].double,
)
)
def __call__(self, seed):
random.seed(seed)
results = []
shows = sorted(self.shows, key=lambda x: x.nrequests / x.slots, reverse=True)
for show in shows:
# On regroupe tous les gens ayant le même rang
groups = dict([(i, []) for i in range(1, self.max_group + 1)])
for member in show.requests:
if self.ranks[member][show] == 0:
raise RuntimeError(member, show.title)
groups[self.ranks[member][show]].append(member)
# On passe à l'attribution
winners = []
losers = []
for i in range(1, self.max_group + 1):
group = list(groups[i])
random.shuffle(group)
for member in group:
if self.choices[member][show].double: # double
if len(winners) + 1 < show.slots:
self.appendResult(winners, member, show)
self.appendResult(winners, member, show)
elif (
not self.choices[member][show].autoquit
and len(winners) < show.slots
):
self.appendResult(winners, member, show)
self.appendResult(losers, member, show)
else:
self.appendResult(losers, member, show)
self.appendResult(losers, member, show)
self.IncrementRanks(member, i, 2)
else: # simple
if len(winners) < show.slots:
self.appendResult(winners, member, show)
else:
self.appendResult(losers, member, show)
self.IncrementRanks(member, i)
results.append((show, winners, losers))
return results

184
bda/forms.py Normal file
View file

@ -0,0 +1,184 @@
from django import forms
from django.forms.models import BaseInlineFormSet
from django.template import loader
from django.utils import timezone
from bda.models import SpectacleRevente
class InscriptionInlineFormSet(BaseInlineFormSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# self.instance is a Participant object
tirage = self.instance.tirage
# set once for all "spectacle" field choices
# - restrict choices to the spectacles of this tirage
# - force_choices avoid many db requests
spectacles = tirage.spectacle_set.select_related("location")
choices = [(sp.pk, str(sp)) for sp in spectacles]
self.force_choices("spectacle", choices)
def force_choices(self, name, choices):
"""Set choices of a field.
As ModelChoiceIterator (default use to get choices of a
ModelChoiceField), it appends an empty selection if requested.
"""
for form in self.forms:
field = form.fields[name]
if field.empty_label is not None:
field.choices = [("", field.empty_label)] + choices
else:
field.choices = choices
class TokenForm(forms.Form):
token = forms.CharField(widget=forms.widgets.Textarea())
class TemplateLabelField(forms.ModelMultipleChoiceField):
"""
Extends ModelMultipleChoiceField to offer two more customization options :
- `label_from_instance` can be used with a template file
- the widget rendering template can be specified with `option_template_name`
"""
def __init__(
self,
label_template_name=None,
context_object_name="obj",
option_template_name=None,
*args,
**kwargs
):
super().__init__(*args, **kwargs)
self.label_template_name = label_template_name
self.context_object_name = context_object_name
if option_template_name is not None:
self.widget.option_template_name = option_template_name
def label_from_instance(self, obj):
if self.label_template_name is None:
return super().label_from_instance(obj)
else:
return loader.render_to_string(
self.label_template_name, context={self.context_object_name: obj}
)
# Formulaires pour revente_manage
class ResellForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["attributions"] = TemplateLabelField(
queryset=participant.attribution_set.filter(
spectacle__date__gte=timezone.now(), paid=True
)
.exclude(revente__seller=participant)
.select_related("spectacle", "spectacle__location", "participant__user"),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/attribution_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="attribution",
)
class AnnulForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["reventes"] = TemplateLabelField(
label="",
queryset=participant.original_shows.filter(
attribution__spectacle__date__gte=timezone.now(), soldTo__isnull=True
)
.select_related(
"attribution__spectacle", "attribution__spectacle__location"
)
.order_by("-date"),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/revente_self_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="revente",
)
class SoldForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["reventes"] = TemplateLabelField(
queryset=participant.original_shows.filter(soldTo__isnull=False)
.exclude(soldTo=participant)
.select_related(
"attribution__spectacle", "attribution__spectacle__location"
),
widget=forms.CheckboxSelectMultiple,
label_template_name="bda/forms/revente_sold_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="revente",
)
# Formulaire pour revente_subscribe
class InscriptionReventeForm(forms.Form):
def __init__(self, tirage, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["spectacles"] = TemplateLabelField(
queryset=tirage.spectacle_set.select_related("location").filter(
date__gte=timezone.now()
),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/spectacle_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="spectacle",
)
# Formulaires pour revente_tirages
class ReventeTirageAnnulForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["reventes"] = TemplateLabelField(
queryset=participant.entered.filter(soldTo__isnull=True).select_related(
"attribution__spectacle", "seller__user"
),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/revente_other_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="revente",
)
class ReventeTirageForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["reventes"] = TemplateLabelField(
queryset=(
SpectacleRevente.objects.filter(
notif_sent=True,
shotgun=False,
tirage_done=False,
attribution__spectacle__tirage=participant.tirage,
)
.exclude(confirmed_entry=participant)
.select_related("attribution__spectacle")
),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/revente_other_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="revente",
)

View file

View file

View file

@ -0,0 +1,101 @@
"""
Crée deux tirages de test et y inscrit les utilisateurs
"""
import os
import random
from django.contrib.auth.models import User
from django.utils import timezone
from bda.models import ChoixSpectacle, Participant, Salle, Spectacle, Tirage
from bda.views import do_tirage
from gestioncof.management.base import MyBaseCommand
# Où sont stockés les fichiers json
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
class Command(MyBaseCommand):
help = "Crée deux tirages de test et y inscrit les utilisateurs."
def handle(self, *args, **options):
# ---
# Tirages
# ---
Tirage.objects.all().delete()
Tirage.objects.bulk_create(
[
Tirage(
title="Tirage de test 1",
ouverture=timezone.now() - timezone.timedelta(days=7),
fermeture=timezone.now(),
active=True,
),
Tirage(
title="Tirage de test 2",
ouverture=timezone.now(),
fermeture=timezone.now() + timezone.timedelta(days=60),
active=True,
),
]
)
tirages = Tirage.objects.all()
# ---
# Salles
# ---
locations = self.from_json("locations.json", DATA_DIR, Salle)
# ---
# Spectacles
# ---
def show_callback(show):
"""
Assigne un tirage, une date et un lieu à un spectacle et décide si
les places sont sur listing.
"""
show.tirage = random.choice(tirages)
show.listing = bool(random.randint(0, 1))
show.date = show.tirage.fermeture + timezone.timedelta(
days=random.randint(60, 90)
)
show.location = random.choice(locations)
return show
shows = self.from_json("shows.json", DATA_DIR, Spectacle, show_callback)
# ---
# Inscriptions
# ---
self.stdout.write("Inscription des utilisateurs aux tirages")
ChoixSpectacle.objects.all().delete()
choices = []
for user in User.objects.filter(profile__is_cof=True):
for tirage in tirages:
part, _ = Participant.objects.get_or_create(user=user, tirage=tirage)
shows = random.sample(
list(tirage.spectacle_set.all()), tirage.spectacle_set.count() // 2
)
for (rank, show) in enumerate(shows):
choices.append(
ChoixSpectacle(
participant=part,
spectacle=show,
priority=rank + 1,
double_choice=random.choice(["1", "double", "autoquit"]),
)
)
ChoixSpectacle.objects.bulk_create(choices)
self.stdout.write("- {:d} inscriptions générées".format(len(choices)))
# ---
# On lance le premier tirage
# ---
self.stdout.write("Lancement du premier tirage")
do_tirage(tirages[0], "dummy_token")

View file

@ -0,0 +1,49 @@
"""
Gestion en ligne de commande des reventes.
"""
from django.core.management import BaseCommand
from django.utils import timezone
from bda.models import SpectacleRevente
class Command(BaseCommand):
help = (
"Envoie les mails de notification et effectue les tirages au sort des reventes"
)
leave_locale_alone = True
def handle(self, *args, **options):
now = timezone.now()
reventes = SpectacleRevente.objects.all()
for revente in reventes:
# 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(
"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))
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")

View file

@ -0,0 +1,33 @@
"""
Gestion en ligne de commande des mails de rappel.
"""
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from bda.models import Spectacle
class Command(BaseCommand):
help = (
"Envoie les mails de rappel des spectacles dont la date approche.\n"
"Ne renvoie pas les mails déjà envoyés."
)
leave_locale_alone = True
def handle(self, *args, **options):
now = timezone.now()
delay = timedelta(days=4)
shows = (
Spectacle.objects.filter(date__range=(now, now + delay))
.filter(tirage__active=True)
.filter(rappel_sent__isnull=True)
.all()
)
for show in shows:
show.send_rappel()
self.stdout.write("Mails de rappels pour %s envoyés avec succès." % show)
if not shows:
self.stdout.write("Aucun mail à envoyer.")

View file

@ -0,0 +1,26 @@
[
{
"name": "Cour\u00f4",
"address": "45 rue d'Ulm, cour\u00f4"
},
{
"name": "K-F\u00eat",
"address": "45 rue d'Ulm, escalier C, niveau -1"
},
{
"name": "Th\u00e9\u00e2tre",
"address": "45 rue d'Ulm, escalier C, niveau -1"
},
{
"name": "Cours Pasteur",
"address": "45 rue d'Ulm, cours pasteur"
},
{
"name": "Salle des actes",
"address": "45 rue d'Ulm, escalier A, niveau 1"
},
{
"name": "Amphi Rataud",
"address": "45 rue d'Ulm, NIR, niveau PB"
}
]

View file

@ -0,0 +1,100 @@
[
{
"description": "Jazz / Funk",
"title": "Un super concert",
"price": 10.0,
"slots_description": "Debout",
"slots": 5
},
{
"description": "Homemade",
"title": "Une super pi\u00e8ce",
"price": 10.0,
"slots_description": "Assises",
"slots": 60
},
{
"description": "Plein air, soleil, bonne musique",
"title": "Concert pour la f\u00eate de la musique",
"price": 5.0,
"slots_description": "Debout, attention \u00e0 la fontaine",
"slots": 30
},
{
"description": "Sous le regard s\u00e9v\u00e8re de Louis Pasteur",
"title": "Op\u00e9ra sans d\u00e9cors",
"price": 5.0,
"slots_description": "Assis sur l'herbe",
"slots": 20
},
{
"description": "Buffet \u00e0 la fin",
"title": "Concert Trouv\u00e8re",
"price": 20.0,
"slots_description": "Assises",
"slots": 15
},
{
"description": "Vive les maths",
"title": "Dessin \u00e0 la craie sur tableau noir",
"price": 10.0,
"slots_description": "Assises, tablette pour prendre des notes",
"slots": 30
},
{
"description": "Une pi\u00e8ce \u00e0 un personnage",
"title": "D\u00e9cors, d\u00e9montage en musique",
"price": 0.0,
"slots_description": "Assises",
"slots": 20
},
{
"description": "Annulera, annulera pas\u00a0?",
"title": "La Nuit",
"price": 27.0,
"slots_description": "",
"slots": 1000
},
{
"description": "Le boum fait sa carte blanche",
"title": "Turbomix",
"price": 10.0,
"slots_description": "Debout les mains en l'air",
"slots": 20
},
{
"description": "Unique repr\u00e9sentation",
"title": "Carinettes et trombone",
"price": 15.0,
"slots_description": "Chaises ikea",
"slots": 10
},
{
"description": "Suivi d'une jam session",
"title": "Percussion sur rondins",
"price": 5.0,
"slots_description": "B\u00fbches",
"slots": 14
},
{
"description": "\u00c9preuve sportive et artistique",
"title": "Bassin aux ernests, nage libre",
"price": 5.0,
"slots_description": "Humides",
"slots": 10
},
{
"description": "Sonore",
"title": "Chant du barde",
"price": 13.0,
"slots_description": "Ne venez pas",
"slots": 20
},
{
"description": "Cocorico",
"title": "Chant du coq",
"price": 4.0,
"slots_description": "bancs",
"slots": 15
}
]

View file

@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name="Attribution",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("given", models.BooleanField(default=False, verbose_name="Donn\xe9e")),
],
),
migrations.CreateModel(
name="ChoixSpectacle",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
(
"priority",
models.PositiveIntegerField(verbose_name=b"Priorit\xc3\xa9"),
),
(
"double_choice",
models.CharField(
default=b"1",
max_length=10,
verbose_name=b"Nombre de places",
choices=[
(b"1", b"1 place"),
(b"autoquit", b"2 places si possible, 1 sinon"),
(b"double", b"2 places sinon rien"),
],
),
),
],
options={
"ordering": ("priority",),
"verbose_name": "voeu",
"verbose_name_plural": "voeux",
},
),
migrations.CreateModel(
name="Participant",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("paid", models.BooleanField(default=False, verbose_name="A pay\xe9")),
(
"paymenttype",
models.CharField(
blank=True,
max_length=6,
verbose_name="Moyen de paiement",
choices=[
(b"cash", "Cash"),
(b"cb", b"CB"),
(b"cheque", "Ch\xe8que"),
(b"autre", "Autre"),
],
),
),
],
),
migrations.CreateModel(
name="Salle",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("name", models.CharField(max_length=300, verbose_name=b"Nom")),
("address", models.TextField(verbose_name=b"Adresse")),
],
),
migrations.CreateModel(
name="Spectacle",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("title", models.CharField(max_length=300, verbose_name=b"Titre")),
("date", models.DateTimeField(verbose_name=b"Date & heure")),
(
"description",
models.TextField(verbose_name=b"Description", blank=True),
),
(
"slots_description",
models.TextField(
verbose_name=b"Description des places", blank=True
),
),
(
"price",
models.FloatField(verbose_name=b"Prix d'une place", blank=True),
),
("slots", models.IntegerField(verbose_name=b"Places")),
(
"priority",
models.IntegerField(default=1000, verbose_name=b"Priorit\xc3\xa9"),
),
(
"location",
models.ForeignKey(to="bda.Salle", on_delete=models.CASCADE),
),
],
options={
"ordering": ("priority", "date", "title"),
"verbose_name": "Spectacle",
},
),
migrations.AddField(
model_name="participant",
name="attributions",
field=models.ManyToManyField(
related_name="attributed_to",
through="bda.Attribution",
to="bda.Spectacle",
),
),
migrations.AddField(
model_name="participant",
name="choices",
field=models.ManyToManyField(
related_name="chosen_by",
through="bda.ChoixSpectacle",
to="bda.Spectacle",
),
),
migrations.AddField(
model_name="participant",
name="user",
field=models.OneToOneField(
to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE
),
),
migrations.AddField(
model_name="choixspectacle",
name="participant",
field=models.ForeignKey(to="bda.Participant", on_delete=models.CASCADE),
),
migrations.AddField(
model_name="choixspectacle",
name="spectacle",
field=models.ForeignKey(
related_name="participants",
to="bda.Spectacle",
on_delete=models.CASCADE,
),
),
migrations.AddField(
model_name="attribution",
name="participant",
field=models.ForeignKey(to="bda.Participant", on_delete=models.CASCADE),
),
migrations.AddField(
model_name="attribution",
name="spectacle",
field=models.ForeignKey(
related_name="attribues", to="bda.Spectacle", on_delete=models.CASCADE
),
),
migrations.AlterUniqueTogether(
name="choixspectacle", unique_together=set([("participant", "spectacle")])
),
]

View file

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
from django.utils import timezone
def fill_tirage_fields(apps, schema_editor):
"""
Create a `Tirage` to fill new field `tirage` of `Participant`
and `Spectacle` already existing.
"""
Participant = apps.get_model("bda", "Participant")
Spectacle = apps.get_model("bda", "Spectacle")
Tirage = apps.get_model("bda", "Tirage")
# These querysets only contains instances not linked to any `Tirage`.
participants = Participant.objects.filter(tirage=None)
spectacles = Spectacle.objects.filter(tirage=None)
if not participants.count() and not spectacles.count():
# No need to create a "trash" tirage.
return
tirage = Tirage.objects.create(
title="Tirage de test (migration)",
active=False,
ouverture=timezone.now(),
fermeture=timezone.now(),
)
participants.update(tirage=tirage)
spectacles.update(tirage=tirage)
class Migration(migrations.Migration):
dependencies = [("bda", "0001_initial")]
operations = [
migrations.CreateModel(
name="Tirage",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("title", models.CharField(max_length=300, verbose_name=b"Titre")),
(
"ouverture",
models.DateTimeField(
verbose_name=b"Date et heure d'ouverture du tirage"
),
),
(
"fermeture",
models.DateTimeField(
verbose_name=b"Date et heure de fermerture du tirage"
),
),
(
"token",
models.TextField(verbose_name=b"Graine du tirage", blank=True),
),
(
"active",
models.BooleanField(default=True, verbose_name=b"Tirage actif"),
),
],
),
migrations.AlterField(
model_name="participant",
name="user",
field=models.ForeignKey(
to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE
),
),
# Create fields `spectacle` for `Participant` and `Spectacle` models.
# These fields are not nullable, but we first create them as nullable
# to give a default value for existing instances of these models.
migrations.AddField(
model_name="participant",
name="tirage",
field=models.ForeignKey(
to="bda.Tirage", null=True, on_delete=models.CASCADE
),
),
migrations.AddField(
model_name="spectacle",
name="tirage",
field=models.ForeignKey(
to="bda.Tirage", null=True, on_delete=models.CASCADE
),
),
migrations.RunPython(fill_tirage_fields, migrations.RunPython.noop),
migrations.AlterField(
model_name="participant",
name="tirage",
field=models.ForeignKey(to="bda.Tirage", on_delete=models.CASCADE),
),
migrations.AlterField(
model_name="spectacle",
name="tirage",
field=models.ForeignKey(to="bda.Tirage", on_delete=models.CASCADE),
),
]

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0002_add_tirage")]
operations = [
migrations.AlterField(
model_name="spectacle",
name="price",
field=models.FloatField(verbose_name=b"Prix d'une place"),
),
migrations.AlterField(
model_name="tirage",
name="active",
field=models.BooleanField(default=False, verbose_name=b"Tirage actif"),
),
]

View file

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0003_update_tirage_and_spectacle")]
operations = [
migrations.AddField(
model_name="spectacle",
name="listing",
field=models.BooleanField(
default=False, verbose_name=b"Les places sont sur listing"
),
preserve_default=False,
),
migrations.AddField(
model_name="spectacle",
name="rappel_sent",
field=models.DateTimeField(
null=True, verbose_name=b"Mail de rappel envoy\xc3\xa9", blank=True
),
),
]

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0004_mails-rappel")]
operations = [
migrations.AlterField(
model_name="choixspectacle",
name="priority",
field=models.PositiveIntegerField(verbose_name="Priorit\xe9"),
),
migrations.AlterField(
model_name="spectacle",
name="priority",
field=models.IntegerField(default=1000, verbose_name="Priorit\xe9"),
),
migrations.AlterField(
model_name="spectacle",
name="rappel_sent",
field=models.DateTimeField(
null=True, verbose_name="Mail de rappel envoy\xe9", blank=True
),
),
]

View file

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.utils import timezone
def forwards_func(apps, schema_editor):
Tirage = apps.get_model("bda", "Tirage")
db_alias = schema_editor.connection.alias
for tirage in Tirage.objects.using(db_alias).all():
if tirage.tokens:
tirage.tokens = 'Before %s\n"""%s"""\n' % (
timezone.now().strftime("%y-%m-%d %H:%M:%S"),
tirage.tokens,
)
tirage.save()
class Migration(migrations.Migration):
dependencies = [("bda", "0005_encoding")]
operations = [
migrations.RenameField("tirage", "token", "tokens"),
migrations.AddField(
model_name="tirage",
name="enable_do_tirage",
field=models.BooleanField(
default=False, verbose_name=b"Le tirage peut \xc3\xaatre lanc\xc3\xa9"
),
),
migrations.RunPython(forwards_func, migrations.RunPython.noop),
]

View file

@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0006_add_tirage_switch")]
operations = [
migrations.CreateModel(
name="CategorieSpectacle",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
(
"name",
models.CharField(max_length=100, verbose_name="Nom", unique=True),
),
],
options={"verbose_name": "Cat\xe9gorie"},
),
migrations.CreateModel(
name="Quote",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("text", models.TextField(verbose_name="Citation")),
("author", models.CharField(max_length=200, verbose_name="Auteur")),
],
),
migrations.AlterModelOptions(
name="spectacle",
options={"ordering": ("date", "title"), "verbose_name": "Spectacle"},
),
migrations.RemoveField(model_name="spectacle", name="priority"),
migrations.AddField(
model_name="spectacle",
name="ext_link",
field=models.CharField(
max_length=500,
verbose_name="Lien vers le site du spectacle",
blank=True,
),
),
migrations.AddField(
model_name="spectacle",
name="image",
field=models.ImageField(
upload_to="imgs/shows/", null=True, verbose_name="Image", blank=True
),
),
migrations.AlterField(
model_name="tirage",
name="enable_do_tirage",
field=models.BooleanField(
default=False, verbose_name="Le tirage peut \xeatre lanc\xe9"
),
),
migrations.AlterField(
model_name="tirage",
name="tokens",
field=models.TextField(verbose_name="Graine(s) du tirage", blank=True),
),
migrations.AddField(
model_name="spectacle",
name="category",
field=models.ForeignKey(
blank=True,
to="bda.CategorieSpectacle",
on_delete=models.CASCADE,
null=True,
),
),
migrations.AddField(
model_name="spectacle",
name="vips",
field=models.TextField(verbose_name="Personnalit\xe9s", blank=True),
),
migrations.AddField(
model_name="quote",
name="spectacle",
field=models.ForeignKey(to="bda.Spectacle", on_delete=models.CASCADE),
),
]

110
bda/migrations/0008_py3.py Normal file
View file

@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0007_extends_spectacle")]
operations = [
migrations.AlterField(
model_name="choixspectacle",
name="double_choice",
field=models.CharField(
verbose_name="Nombre de places",
choices=[
("1", "1 place"),
("autoquit", "2 places si possible, 1 sinon"),
("double", "2 places sinon rien"),
],
max_length=10,
default="1",
),
),
migrations.AlterField(
model_name="participant",
name="paymenttype",
field=models.CharField(
blank=True,
choices=[
("cash", "Cash"),
("cb", "CB"),
("cheque", "Chèque"),
("autre", "Autre"),
],
max_length=6,
verbose_name="Moyen de paiement",
),
),
migrations.AlterField(
model_name="salle",
name="address",
field=models.TextField(verbose_name="Adresse"),
),
migrations.AlterField(
model_name="salle",
name="name",
field=models.CharField(verbose_name="Nom", max_length=300),
),
migrations.AlterField(
model_name="spectacle",
name="date",
field=models.DateTimeField(verbose_name="Date & heure"),
),
migrations.AlterField(
model_name="spectacle",
name="description",
field=models.TextField(verbose_name="Description", blank=True),
),
migrations.AlterField(
model_name="spectacle",
name="listing",
field=models.BooleanField(verbose_name="Les places sont sur listing"),
),
migrations.AlterField(
model_name="spectacle",
name="price",
field=models.FloatField(verbose_name="Prix d'une place"),
),
migrations.AlterField(
model_name="spectacle",
name="slots",
field=models.IntegerField(verbose_name="Places"),
),
migrations.AlterField(
model_name="spectacle",
name="slots_description",
field=models.TextField(verbose_name="Description des places", blank=True),
),
migrations.AlterField(
model_name="spectacle",
name="title",
field=models.CharField(verbose_name="Titre", max_length=300),
),
migrations.AlterField(
model_name="tirage",
name="active",
field=models.BooleanField(verbose_name="Tirage actif", default=False),
),
migrations.AlterField(
model_name="tirage",
name="fermeture",
field=models.DateTimeField(
verbose_name="Date et heure de fermerture du tirage"
),
),
migrations.AlterField(
model_name="tirage",
name="ouverture",
field=models.DateTimeField(
verbose_name="Date et heure d'ouverture du tirage"
),
),
migrations.AlterField(
model_name="tirage",
name="title",
field=models.CharField(verbose_name="Titre", max_length=300),
),
]

View file

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0008_py3")]
operations = [
migrations.CreateModel(
name="SpectacleRevente",
fields=[
(
"id",
models.AutoField(
serialize=False,
primary_key=True,
auto_created=True,
verbose_name="ID",
),
),
(
"date",
models.DateTimeField(
verbose_name="Date de mise en vente",
default=django.utils.timezone.now,
),
),
(
"notif_sent",
models.BooleanField(
verbose_name="Notification envoyée", default=False
),
),
(
"tirage_done",
models.BooleanField(verbose_name="Tirage effectué", default=False),
),
],
options={"verbose_name": "Revente"},
),
migrations.AddField(
model_name="participant",
name="choicesrevente",
field=models.ManyToManyField(
to="bda.Spectacle", related_name="subscribed", blank=True
),
),
migrations.AddField(
model_name="spectaclerevente",
name="answered_mail",
field=models.ManyToManyField(
to="bda.Participant", related_name="wanted", blank=True
),
),
migrations.AddField(
model_name="spectaclerevente",
name="attribution",
field=models.OneToOneField(
to="bda.Attribution", on_delete=models.CASCADE, related_name="revente"
),
),
migrations.AddField(
model_name="spectaclerevente",
name="seller",
field=models.ForeignKey(
to="bda.Participant",
on_delete=models.CASCADE,
verbose_name="Vendeur",
related_name="original_shows",
),
),
migrations.AddField(
model_name="spectaclerevente",
name="soldTo",
field=models.ForeignKey(
to="bda.Participant",
on_delete=models.CASCADE,
verbose_name="Vendue à",
null=True,
blank=True,
),
),
]

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from datetime import timedelta
from django.db import migrations, models
from django.utils import timezone
def forwards_func(apps, schema_editor):
SpectacleRevente = apps.get_model("bda", "SpectacleRevente")
for revente in SpectacleRevente.objects.all():
is_expired = timezone.now() > revente.date_tirage()
is_direct = (
revente.attribution.spectacle.date >= revente.date
and timezone.now() > revente.date + timedelta(minutes=15)
)
revente.shotgun = is_expired or is_direct
revente.save()
class Migration(migrations.Migration):
dependencies = [("bda", "0009_revente")]
operations = [
migrations.AddField(
model_name="spectaclerevente",
name="shotgun",
field=models.BooleanField(
default=False, verbose_name="Disponible imm\xe9diatement"
),
),
migrations.RunPython(forwards_func, migrations.RunPython.noop),
]

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0010_spectaclerevente_shotgun")]
operations = [
migrations.AddField(
model_name="tirage",
name="appear_catalogue",
field=models.BooleanField(
default=False, verbose_name="Tirage à afficher dans le catalogue"
),
)
]

View file

@ -0,0 +1,31 @@
# -*- 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
),
),
]

View file

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def swap_double_choice(apps, schema_editor):
choices = apps.get_model("bda", "ChoixSpectacle").objects
choices.filter(double_choice="double").update(double_choice="tmp")
choices.filter(double_choice="autoquit").update(double_choice="double")
choices.filter(double_choice="tmp").update(double_choice="autoquit")
class Migration(migrations.Migration):
dependencies = [("bda", "0011_tirage_appear_catalogue")]
operations = [
# Temporarily allow an extra "tmp" value for the `double_choice` field
migrations.AlterField(
model_name="choixspectacle",
name="double_choice",
field=models.CharField(
verbose_name="Nombre de places",
max_length=10,
default="1",
choices=[
("tmp", "tmp"),
("1", "1 place"),
("double", "2 places si possible, 1 sinon"),
("autoquit", "2 places sinon rien"),
],
),
),
migrations.RunPython(swap_double_choice, migrations.RunPython.noop),
migrations.AlterField(
model_name="choixspectacle",
name="double_choice",
field=models.CharField(
verbose_name="Nombre de places",
max_length=10,
default="1",
choices=[
("1", "1 place"),
("double", "2 places si possible, 1 sinon"),
("autoquit", "2 places sinon rien"),
],
),
),
]

View file

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-05-24 19:23
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("bda", "0012_notif_time"), ("bda", "0012_swap_double_choice")]
operations = []

View file

@ -0,0 +1,31 @@
# Generated by Django 2.2 on 2019-06-03 19:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0013_merge_20180524_2123")]
operations = [
migrations.AddField(
model_name="attribution",
name="paid",
field=models.BooleanField(default=False, verbose_name="Payée"),
),
migrations.AddField(
model_name="attribution",
name="paymenttype",
field=models.CharField(
blank=True,
choices=[
("cash", "Cash"),
("cb", "CB"),
("cheque", "Chèque"),
("autre", "Autre"),
],
max_length=6,
verbose_name="Moyen de paiement",
),
),
]

View file

@ -0,0 +1,37 @@
# Generated by Django 2.2 on 2019-06-03 19:30
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db import migrations
def set_attr_payment(apps, schema_editor):
Attribution = apps.get_model("bda", "Attribution")
for attr in Attribution.objects.all():
attr.paid = attr.participant.paid
attr.paymenttype = attr.participant.paymenttype
attr.save()
def set_participant_payment(apps, schema_editor):
Participant = apps.get_model("bda", "Participant")
for part in Participant.objects.all():
attr_set = part.attribution_set
part.paid = attr_set.exists() and not attr_set.filter(paid=False).exists()
try:
# S'il n'y a qu'un seul type de paiement, on le set
part.paymenttype = (
attr_set.values_list("paymenttype", flat=True).distinct().get()
)
# Sinon, whatever
except (ObjectDoesNotExist, MultipleObjectsReturned):
pass
part.save()
class Migration(migrations.Migration):
dependencies = [("bda", "0014_attribution_paid_field")]
operations = [
migrations.RunPython(set_attr_payment, set_participant_payment, atomic=True)
]

View file

@ -0,0 +1,13 @@
# Generated by Django 2.2 on 2019-06-03 19:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("bda", "0015_move_bda_payment")]
operations = [
migrations.RemoveField(model_name="participant", name="paid"),
migrations.RemoveField(model_name="participant", name="paymenttype"),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-09-18 16:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0016_delete_participant_paid")]
operations = [
migrations.AddField(
model_name="participant",
name="accepte_charte",
field=models.BooleanField(
default=False, verbose_name="A accepté la charte BdA"
),
)
]

View file

@ -0,0 +1,38 @@
# Generated by Django 2.2.12 on 2020-10-21 16:18
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bda", "0017_participant_accepte_charte"),
]
operations = [
migrations.AlterModelOptions(
name="participant",
options={"ordering": ("-tirage", "user__last_name", "user__first_name")},
),
migrations.AddField(
model_name="tirage",
name="archived",
field=models.BooleanField(default=False, verbose_name="Archivé"),
),
migrations.AlterField(
model_name="participant",
name="tirage",
field=models.ForeignKey(
limit_choices_to={"archived": False},
on_delete=django.db.models.deletion.CASCADE,
to="bda.Tirage",
),
),
migrations.AddConstraint(
model_name="participant",
constraint=models.UniqueConstraint(
fields=("tirage", "user"), name="unique_tirage"
),
),
]

View file

490
bda/models.py Normal file
View file

@ -0,0 +1,490 @@
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

125
bda/static/bda/css/bda.css Normal file
View file

@ -0,0 +1,125 @@
form#tokenform {
text-align: center;
font-size: 2em;
}
label {
margin-right: 10px;
vertical-align: top;
}
form#tokenform textarea {
font-size: 2em;
width: 350px;
height: 200px;
font-family: 'Droif Serif', serif;
}
/* wft ?
input {
width: 400px;
font-size: 2em;
}*/
ul.losers {
display: inline;
margin: 0;
padding: 0;
}
ul.losers li {
display: inline;
}
span.details {
font-size: 0.7em;
}
td {
border: 0px solid black;
padding: 2px;
}
.attribresult {
margin: 10px 0px;
}
.spectacle-passe {
opacity:0.5;
}
/** JQuery-Confirm box **/
.jconfirm .jconfirm-bg {
background-color: rgb(0,0,0,0.6) !important;
}
.jconfirm .jconfirm-box {
padding:0;
border-radius:0 !important;
font-family:Roboto;
}
.jconfirm .jconfirm-box .content-pane {
border-bottom:1px solid #ddd;
margin: 0px !important;
}
.jconfirm .jconfirm-box .content {
padding: 5px;
}
.jconfirm .jconfirm-box .content-pane {
border-bottom:1px solid #ddd;
margin: 0px !important;
}
.jconfirm .jconfirm-box .content {
padding: 10px;
}
.jconfirm .jconfirm-box .content a,
.jconfirm .jconfirm-box .content a:hover {
color: #D81138;
font-weight: bold;
}
.jconfirm .jconfirm-box .buttons {
margin-top:-6px; /* j'arrive pas à voir pk y'a un espace au dessus sinon... */
padding:0;
height:40px;
}
.jconfirm .jconfirm-box .buttons button {
min-width:40px;
height:100%;
margin:0;
margin:0 !important;
border-radius: 0 !important;
}
.jconfirm .jconfirm-box .buttons button:first-child:focus,
.jconfirm .jconfirm-box .buttons button:first-child:hover {
color:#FFF !important;
background:forestgreen !important;
}
.jconfirm .jconfirm-box .buttons button:nth-child(2):focus,
.jconfirm .jconfirm-box .buttons button:nth-child(2):hover {
color:#FFF !important;
background:#D93A32 !important;
}
.jconfirm .jconfirm-box div.title-c .title {
display: block;
padding:0 15px;
height:40px;
line-height:40px;
font-family:Dosis;
font-size:20px;
font-weight:bold;
color:#FFF;
background-color:rgb(222, 130, 107);
}

View file

@ -0,0 +1,28 @@
{% extends "bda-attrib.html" %}
{% block extracontent %}
<h2>Attributions (détails)</h2>
<h3 class="horizontal-title">Token :</h3>
<pre>{{ token }}</pre>
<h3 class="horizontal-title">Placés : {{ total_slots }} ; Déçus : {{ total_losers }}</h3>
<table>
{% for member, shows in members2 %}
<tr>
<td>{{ member.user.get_full_name }}</td>
<td>{{ member.user.email }}</td>
<td>Total: {{ member.total }}€</td>
<td style="width: 120px;"></td>
</tr>
{% for show in shows %}
<tr>
<td></td>
<td></td>
<td>{{ show }}</td>
<td></td>
</tr>
{% endfor %}
{% endfor %}
</table>
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block extra_head %}
<link type="text/css" rel="stylesheet" href="{% static "bda/css/bda.css" %}" />
{% endblock %}
{% block realcontent %}
<h2>Attributions</h2>
<br />
<p class="success">Pour raison de sécurité, le lancement du tirage
a été désactivé. Vous pouvez le réactiver dans
l'<a href="{% url "admin:index" %}">interface admin</a></p>
<h3 class="horizontal-title">Token :</h3>
<pre>{{ token }}</pre>
<h3 class="horizontal-title">Placés : {{ total_slots }} ; Déçus : {{ total_losers }}</h3>
{% if user.profile.is_buro %}<h3 class="horizontal-title">Déficit total: {{ total_deficit }} €, Opéra: {{ opera_deficit }} €, Attribué: {{ total_sold }} €</h3>{% endif %}
<h3 class="horizontal-title">Temps de calcul : {{ duration|floatformat }}s</h3>
{% for show, members, losers in results %}
<div class="attribresult">
<h3 class="horizontal-title">{{ show.title }} - {{ show.date }} @ {{ show.location }}</h3>
<p>
<strong>{{ show.nrequests }} demandes pour {{ show.slots }} places</strong>
{{ show.price }}€ par place{% if user.profile.is_buro and show.nrequests < show.slots %}, {{ show.deficit }} de déficit{% endif %}
</p>
Places :
<ul>
{% for member, rank, origrank, double in members %}
<li>{{ member.user.get_full_name }} <span class="details">(souhait {{ origrank }} &mdash; rang {{ rank }})</span></li>
{% endfor %}
</ul>
Déçus :
{% if not losers %}/{% else %}
<ul class="losers">
{% for member, rank, origrank, double in losers %}
{% if not forloop.first %} ; {% endif %}
<li>{{ member.user.get_full_name }} <span class="details">(souhait {{ origrank }} &mdash; rang {{ rank }})</span></li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
{% block extracontent %}
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>{{ spectacle }}</h2>
<textarea style="width: 100%; height: 100px; margin-top: 10px;">
{% for attrib in spectacle.attribues.all %}{{ attrib.participant.user.email }}, {% endfor %}</textarea>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Tirage au sort du BdA</h2>
<form action="" method="post" id="tokenform">
{% csrf_token %}
<strong>La graine :</strong>
<div>
{{ form.token }}
</div>
<input type="submit" onsubmit="return confirm('Voulez vous lancer le Tirage maintenant ?\n\nCECI REMETTRA À ZÉRO TOUTES LES DONNÉES si le tirage a déjà été lancé.')" value="Go" />
</form>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Impayés</h2>
<textarea style="width: 100%; height: 100px; margin-top: 10px;">
{% for participant in unpaid %}{{ participant.user.email }}, {% endfor %}</textarea>
<h3>Total&nbsp: {{ unpaid|length }}</h3>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>État des inscriptions BdA</h2>
<table class="table table-striped etat-bda">
<thead>
<tr>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Places</th>
<th data-sort="int">Demandes</th>
<th data-sort="float">Ratio</th>
</tr>
</thead>
<tbody>
{% for spectacle in spectacles %}
<tr>
<td>{{ spectacle.title }}</td>
<td data-sort-value="{{ spectacle.timestamp }}">{{ spectacle.date }}</td>
<td data-sort-value="{{ spectacle.location }}">{{ spectacle.location }}</td>
<td data-sort-value="{{ spectacle.slots }}">{{ spectacle.slots }} places</td>
<td data-sort-value="{{ spectacle.total }}">{{ spectacle.total }} demandes</td>
<td data-sort-value="{{ spectacle.ratio |stringformat:".3f" }}"
class={% if spectacle.ratio < 1.0 %}
"greenratio"
{% else %}
{% if spectacle.ratio < 2.5 %}
"orangeratio"
{% else %}
"redratio"
{% endif %}
{% endif %}>
{{ spectacle.ratio |floatformat }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<span class="bda-prix">
Total&nbsp;: {{ total }} place{{ total|pluralize }} demandée{{ total|pluralize }}
sur {{ proposed }} place{{ proposed|pluralize }} proposée{{ proposed|pluralize }}
</span>
<script type="text/javascript">
$(function(){
$("table.etat-bda").stupidtable();
});
</script>
{% endblock %}

View file

@ -0,0 +1 @@
{% include 'bda/forms/spectacle_label_table.html' with spectacle=attribution.spectacle %}

View file

@ -0,0 +1,4 @@
<tr>
<td><input type="{{ widget.type }}" name="{{ widget.name }}" {% if widget.value != None %} value="{{ widget.value|stringformat:'s' }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}></td>
{{ widget.label }}
</tr>

View file

@ -0,0 +1 @@
<td data-sort-value="{{ revente.date_tirage | date:"U" }}">{{ revente.date_tirage }}</td>

View file

@ -0,0 +1,3 @@
{% include 'bda/forms/spectacle_label_table.html' with spectacle=revente.attribution.spectacle %}
{% with user=revente.seller.user %} <td>{{user.first_name}} {{user.last_name}}</td> {% endwith%}
{% include 'bda/forms/date_tirage.html' %}

View file

@ -0,0 +1,2 @@
{% include 'bda/forms/spectacle_label_table.html' with spectacle=revente.attribution.spectacle %}
{% include 'bda/forms/date_tirage.html' %}

View file

@ -0,0 +1,4 @@
{% include 'bda/forms/spectacle_label_table.html' with spectacle=revente.attribution.spectacle %}
{% with user=revente.soldTo.user %}
<td><a href="mailto:{{ user.email }}">{{user.first_name}} {{user.last_name}}</a></td>
{% endwith %}

View file

@ -0,0 +1,4 @@
<td>{{ spectacle.title }}</td>
<td data-sort-value="{{ spectacle.timestamp }}">{{ spectacle.date }}</td>
<td data-sort-value="{{ spectacle.location }}">{{ spectacle.location }}</td>
<td data-sort-value="{{ spectacle.price |stringformat:".3f" }}">{{ spectacle.price |floatformat }}€</td>

View file

@ -0,0 +1,41 @@
{% load bootstrap %}
{{ formset.non_form_errors.as_ul }}
<table id="bda_formset" class="form table">
{{ formset.management_form }}
{% for form in formset.forms %}
{% if forloop.first %}
<thead><tr>
{% for field in form.visible_fields %}
{% if field.name != "DELETE" and field.name != "priority" %}
<th class="bda-field-{{ field.name }}">{{ field.label|safe|capfirst }}</th>
{% endif %}
{% endfor %}
<th><sup>1</sup></th>
</tr></thead>
<tbody class="bda_formset_content">
{% endif %}
<tr class="{% cycle 'row1' 'row2' %} dynamic-form {% if form.instance.pk %}has_original{% endif %}">
{% for field in form.visible_fields %}
{% if field.name != "DELETE" and field.name != "priority" %}
<td class="bda-field-{{ field.name }}">
{% if forloop.first %}
{{ form.non_field_errors }}
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
{% endif %}
{{ field.errors.as_ul }}
{{ field | bootstrap }}
</td>
{% endif %}
{% endfor %}
<td class="tools-cell"><div class="tools">
<a href="javascript://" class="glyphicon glyphicon-sort drag-btn" title="Déplacer"></a>
<input type="checkbox" name="{{ form.DELETE.html_name }}" style="display: none;" />
<input type="hidden" name="{{ form.priority.html_name }}" style="{{ form.priority.value }}" />
<a href="javascript://" class="glyphicon glyphicon-remove remove-btn" title="Supprimer"></a>
</div>
<div class="spacer"></div>
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,169 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block extra_head %}
<script type="text/javascript" src="{% static 'vendor/jquery/jquery-ui.min.js' %}" ></script>
<script type="text/javascript" src="{% static "vendor/jquery/jquery-confirm.js" %}"></script>
<script type="text/javascript" src="{% static 'gestioncof/vendor/jquery.ui.touch-punch.min.js' %}" ></script>
<link type="text/css" rel="stylesheet" href="{% static 'vendor/jquery/jquery-confirm.css' %}">
<link type="text/css" rel="stylesheet" href="{% static 'vendor/jquery/jquery-ui.min.css' %}" />
<link type="text/css" rel="stylesheet" href="{% static 'bda/css/bda.css' %}" />
{% endblock %}
{% block realcontent %}
<script type="text/javascript">
var django = {
"jQuery": jQuery.noConflict(true)
};
(function($) {
cloneMore = function(selector, type) {
var newElement = $(selector).clone(true);
var total = $('#id_' + type + '-TOTAL_FORMS').val();
newElement.find(':input').each(function() {
var name = $(this).attr('name').replace('-' + (total-1) + '-','-' + total + '-');
var id = 'id_' + name;
$(this).attr({'name': name, 'id': id}).val('').removeAttr('checked');
});
newElement.find('label').each(function() {
var newFor = $(this).attr('for').replace('-' + (total-1) + '-','-' + total + '-');
$(this).attr('for', newFor);
});
// Cloning <select> element doesn't properly propagate the default
// selected <option>, so we set it manually.
newElement.find('select').each(function (index, select) {
var defaultValue = $(select).find('option[selected]').val();
if (typeof defaultValue !== 'undefined') {
$(select).val(defaultValue);
}
});
total++;
$('#id_' + type + '-TOTAL_FORMS').val(total);
$(selector).after(newElement);
}
deleteButtonHandler = function(elem) {
elem.bind("click", function() {
var deleteInput = $(this).prev().prev(),
form = $(this).parents(".dynamic-form").first();
// callback
// toggle options.predeleteCssClass and toggle checkbox
if (form.hasClass("has_original")) {
form.toggleClass("predelete");
if (deleteInput.attr("checked")) {
deleteInput.attr("checked", false);
} else {
deleteInput.attr("checked", true);
}
} else {
// Reset the default values
var selects = $(form).find("select");
$(selects[0]).val("");
$(selects[1]).val("1");
}
// callback
});
};
$(document).ready(function($) {
deleteButtonHandler($("table#bda_formset tbody.bda_formset_content").find("a.remove-btn"));
$("table#bda_formset tbody.bda_formset_content").sortable({
handle: "a.drag-btn",
items: "tr",
axis: "y",
appendTo: 'body',
forceHelperSize: true,
placeholder: 'ui-sortable-placeholder',
forcePlaceholderSize: true,
containment: 'form#bda_form',
tolerance: 'pointer',
start: function(evt, ui) {
var template = "",
len = ui.item.children("td").length;
for (var i = 0; i < len; i++) {
template += "<td style='height:" + (ui.item.outerHeight() + 12 ) + "px' class='placeholder-cell'>&nbsp;</td>"
}
template += "";
ui.placeholder.html(template);
},
stop: function(evt, ui) {
// Toggle div.table twice to remove webkits border-spacing bug
$("table#bda_formset").toggle().toggle();
},
});
$("#bda_form").bind("submit", function(){
var sortable_field_name = "priority";
var i = 1;
$(".bda_formset_content").find("tr").each(function(){
var fields = $(this).find("td :input[value]"),
select = $(this).find("td select");
if (select.val() && fields.serialize()) {
$(this).find("input[name$='"+sortable_field_name+"']").val(i);
i++;
}
});
});
});
})(django.jQuery);
</script>
<h2 class="no-bottom-margin">Inscription au tirage au sort du BdA</h2>
<form class="form-horizontal" id="bda_form" method="post" action="{% url 'bda-tirage-inscription' tirage.id %}">
{% csrf_token %}
{% include "bda/inscription-formset.html" %}
<div class="inscription-bottom">
<span class="bda-prix">Prix total actuel : {{ total_price }}€</span>
<div class="pull-right">
<input type="button" class="btn btn-default" value="Ajouter un autre v&oelig;u" id="add_more">
<script>
django.jQuery('#add_more').click(function() {
cloneMore('tbody.bda_formset_content tr:last-child', 'choixspectacle_set');
});
</script>
<input type="hidden" name="dbstate" value="{{ dbstate }}" />
<input type="submit" class="btn btn-primary" id="bda-inscr" value="Enregistrer" />
</div>
<p class="footnotes">
<sup>1</sup>: cette liste de v&oelig;ux est ordonnée (du plus important au moins important), pour ajuster la priorité vous pouvez déplacer chaque v&oelig;u.<br />
</p>
</div>
</form>
{% if not charte %}
<script>
(function ($) {
var charte_ok = false ;
function link_charte() {
$.confirm({
title: 'Charte du BdA',
columnClass: 'col-md-6 col-md-offset-3',
content: `
<div>
En vous inscrivant à ce tirage du Bureau des Arts, vous vous engagez à \
respecter la charte du BdA:</br> \
<a target="_blank" href='https://bda.ens.fr/lequipe/charte-bda/'>https://bda.ens.fr/lequipe/charte-bda/</a>
</div>`,
backgroundDismiss: true,
opacity: 1,
animation:'top',
closeAnimation:'bottom',
keyboardEnabled: true,
confirmButton: '<span class="glyphicon glyphicon-ok"></span>',
cancelButton: '<span class="glyphicon glyphicon-remove"></span>',
confirm: function() {
charte_ok = true ;
$("#bda_form").submit();
},
});
}
$(document).ready(function($) {
$("#bda_form").submit(function(e) {
if (!charte_ok) {
e.preventDefault();
link_charte();
}
})
})
})(django.jQuery);
</script>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,41 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Mails de rappels</h2>
{% if sent %}
<h3>Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes</h3>
<ul>
{% for member in members %}
<li>{{ member.get_full_name }} ({{ member.email }})</li>
{% endfor %}
</ul>
{% else %}
<h3>Voulez vous envoyer les mails de rappel pour le spectacle {{ show.title }}&nbsp;?</h3>
{% endif %}
<div class="empty-form">
{% if not sent %}
<form action="" method="post">
{% csrf_token %}
<div class="pull-right">
<input class="btn btn-primary" type="submit" value="Envoyer" />
</div>
</form>
{% endif %}
</div>
<hr \>
<h3>Forme des mails</h3>
<h4>Une seule place</h4>
{% for part in exemple_mail_1place %}
<pre>{{ part }}</pre>
{% endfor %}
<h4>Deux places</h4>
{% for part in exemple_mail_2places %}
<pre>{{ part }}</pre>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,10 @@
Cher-e {{ member.first_name }},
Tu t'es inscrit-e pour le tirage au sort du BdA. Malheureusement, tu n'as
obtenu aucune place.
Nous proposons cependant de nombreuses offres hors-tirage tout au long de
l'année, et nous t'invitons à nous contacter si l'une d'entre elles
t'intéresse !
--
Le Bureau des Arts

View file

@ -0,0 +1,31 @@
Cher-e {{ member.first_name }},
Tu t'es inscrit-e pour le tirage au sort du BdA. Tu as été sélectionné-e
pour les spectacles suivants :
{% for place in places %}
- 1 place pour {{ place }}{% endfor %}
*Paiement*
L'intégralité de ces places de spectacles est à régler dès maintenant et AVANT
vendredi prochain, au bureau du COF pendant les heures de permanences (du lundi au vendredi
entre 12h et 14h, et entre 18h et 20h). Des facilités de paiement sont bien
évidemment possibles : nous pouvons ne pas encaisser le chèque immédiatement,
ou bien découper votre paiement en deux fois. Pour ceux qui ne pourraient pas
venir payer au bureau, merci de nous contacter par mail.
*Mode de retrait des places*
Au moment du paiement, certaines places vous seront remises directement,
d'autres seront à récupérer au cours de l'année, d'autres encore seront
nominatives et à retirer le soir même dans les théâtres correspondants.
Pour chaque spectacle, vous recevrez un mail quelques jours avant la
représentation vous indiquant le mode de retrait.
Nous vous rappelons que l'obtention de places du BdA vous engage à
respecter les règles de fonctionnement :
https://bda.ens.fr/lequipe/charte-bda/
Un système de revente des places via les mails BdA-revente est disponible
directement sur votre compte GestioCOF.
En vous souhaitant de très beaux spectacles tout au long de l'année,
--
Le Bureau des Arts

View file

@ -0,0 +1,23 @@
Bonjour {{ member.first_name }},
Nous te rappellons que tu as eu la chance d'obtenir {{ nb_attr|pluralize:"une place,deux places" }}
pour {{ show.title }}, le {{ show.date }} au {{ show.location }}. N'oublie pas de t'y rendre !
{% if nb_attr == 2 %}
Tu as obtenu deux places pour ce spectacle. Nous te rappelons que
ces places sont strictement réservées aux personnes de moins de 28 ans.
{% endif %}
{% if show.listing %}Pour ce spectacle, tu as reçu {{ nb_attr|pluralize:"une place,des places" }} sur
listing. Il te faudra donc te rendre 15 minutes en avance sur les lieux de la représentation
pour {{ nb_attr|pluralize:"la,les" }} retirer.
{% else %}Pour assister à ce spectacle, tu dois présenter les billets qui ont
été distribués au burô.
{% endif %}
Si tu ne peux plus assister à cette représentation, tu peux
revendre ta place via BdA-revente, accessible directement sur
GestioCOF (lien "revendre une place du premier tirage" sur la page
d'accueil https://www.cof.ens.fr/gestion/).
En te souhaitant un excellent spectacle,
--
Le Bureau des Arts

View file

@ -0,0 +1,12 @@
Bonjour {{ member.first_name }}
Une place pour le spectacle {{ show.title }} ({{ show.date }})
a été postée sur BdA-Revente.
Si ce spectacle t'intéresse toujours, merci de nous le signaler en cliquant
sur ce lien : https://{{ site }}{% url "bda-revente-confirm" revente.id %}.
Dans le cas où plusieurs personnes seraient intéressées, nous procèderons à
un tirage au sort le {{ revente.date_tirage|date:"DATE_FORMAT" }}.
Chaleureusement,
Le BdA

View file

@ -0,0 +1,13 @@
Bonjour {{ vendeur.first_name }},
Tu tes bien inscrit·e pour revendre une place pour {{ show.title }}.
{% with revente.date_tirage as time %}
Le tirage au sort entre tout·e·s les racheteuse·eur·s potentiel·le·s aura lieu
le {{ time|date:"DATE_FORMAT" }} à {{ time|time:"TIME_FORMAT" }} (dans {{time|timeuntil }}).
Si personne ne sest inscrit pour racheter la place, celle-ci apparaîtra parmi
les « Places disponibles immédiatement à la revente » sur GestioCOF.
{% endwith %}
Bonne revente !
Le Bureau des Arts

View file

@ -0,0 +1,6 @@
Bonjour {{ vendeur.first_name }} !
Je souhaiterais racheter ta place pour {{ show.title }} le {{ show.date }} ({{ show.location }}) à {{ show.price|floatformat:2 }}€.
Contacte-moi si tu es toujours intéressé·e !
{{ acheteur.get_full_name }} ({{ acheteur.email }})

View file

@ -0,0 +1,11 @@
Bonjour {{ member.first_name }}
Une place pour le spectacle {{ show.title }} ({{ show.date }})
a été postée sur BdA-Revente.
Puisque ce spectacle a lieu dans moins de 24h, il n'y a pas de tirage au sort pour
cette place : elle est disponible immédiatement à l'adresse
https://{{ site }}{% url "bda-revente-buy" show.id %}, à la disposition de tous.
Chaleureusement,
Le BdA

View file

@ -0,0 +1,9 @@
Bonjour {{ acheteur.first_name }},
Tu t'étais inscrit·e pour la revente de la place de {{ vendeur.get_full_name }}
pour {{ show.title }}.
Malheureusement, une autre personne a été tirée au sort pour racheter la place.
Tu pourras certainement retenter ta chance pour une autre revente !
À très bientôt,
Le Bureau des Arts

View file

@ -0,0 +1,7 @@
Bonjour {{ vendeur.first_name }},
La personne tirée au sort pour racheter ta place pour {{ show.title }} est {{ acheteur.get_full_name }}.
Tu peux le/la contacter à l'adresse {{ acheteur.email }}, ou en répondant à ce mail.
Chaleureusement,
Le BdA

View file

@ -0,0 +1,7 @@
Bonjour {{ acheteur.first_name }},
Tu as été tiré·e au sort pour racheter une place pour {{ show.title }} le {{ show.date }} ({{ show.location }}) à {{ show.price|floatformat:2 }}€.
Tu peux contacter le/la vendeur·se à l'adresse {{ vendeur.email }}.
Chaleureusement,
Le BdA

View file

@ -0,0 +1,71 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>{{ spectacle }}</h2>
<table class='table table-striped etat-bda'>
<thead>
<tr>
<th data-sort="string">Nom</th>
<th data-sort="int">Places</th>
<th data-sort="string">Adresse Mail</th>
<th data-sort="string">Payé</th>
<th data-sort="string">Donné</th>
</tr>
</thead>
<tbody>
{% for participant in participants %}
<tr>
<td data-sort-value="{{ participant.name}}">{{participant.name}}</td>
<td data-sort-value="{{participant.nb_places}}">{{participant.nb_places}} place{{participant.nb_places|pluralize}}</td>
<td data-sort-value="{{participant.email}}">{{participant.email}}</td>
<td data-sort-value="{{ participant.paid}}" class={%if participant.paid %}"greenratio"{%else%}"redratio"{%endif%}>
{% if participant.paid %}Oui{% else %}Non{%endif%}
</td>
<td data-sort-value="{{participant.given}}" class={%if participant.given == participant.nb_places %}"greenratio"
{%elif participant.given == 0%}"redratio"
{%else%}"orangeratio"
{%endif%}>
{% if participant.given == participant.nb_places %}Oui
{% elif participant.given == 0 %}Non
{% else %}{{participant.given}}/{{participant.nb_places}}
{%endif%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3><a href="{% url "admin:bda_attribution_add" %}?spectacle={{spectacle.id}}"><span class="glyphicon glyphicon-plus-sign"></span> Ajouter une attribution</a></h3>
<div>
<div>
<button class="btn btn-default" type="button" onclick="toggle('export-mails')">Afficher/Cacher mails participant⋅e⋅s</button>
<pre id="export-mails" style="display:none">{% spaceless %}
{% for participant in participants %}{{ participant.email }}, {% endfor %}
{% endspaceless %}</pre>
</div>
<div>
<button class="btn btn-default" type="button" onclick="toggle('export-salle')">Afficher/Cacher liste noms</button>
<pre id="export-salle" style="display:none">{% spaceless %}
{% for participant in participants %}{{ participant.name }} : {{ participant.nb_places }} place{{ participant.nb_places|pluralize }}
{% endfor %}
{% endspaceless %}</pre>
</div>
<div>
<a href="{% url 'bda-rappels' spectacle.id %}">Page d'envoi manuel des mails de rappel</a>
</div>
<script type="text/javascript">
function toggle(id) {
var pre = document.getElementById(id) ;
pre.style.display = pre.style.display == "none" ? "block" : "none" ;
}
</script>
<script type="text/javascript">
$(function(){
$("table.etat-bda").stupidtable();
});
</script>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "base_title.html" %}
{% block realcontent %}
{% if choices %}
<h3>Vos v&oelig;ux:</h3>
<ol>
{% for choice in choices %}
<li>{{ choice.spectacle }}{% if choice.double %} (deux places{% if autoquit %}, abandon automatique{% endif %}){% endif %}</li>
{% endfor %}
</ol>
{% else %}
<h3>Vous n'avez enregistré aucun v&oelig;u pour le tirage au sort</h3>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2><strong>Places attribuées</strong></h3>
{% if places %}
<table class="table table-striped">
{% for place in places %}
<tr>
<td>{{place.spectacle.title}}</td>
<td>{{place.spectacle.location}}</td>
<td>{{place.spectacle.date}}</td>
<td>{% if place.double %}deux places{%else%}une place{% endif %}</td>
<td>{% if place.spectacle.listing %}sur listing{% else %}place physique{% endif %}</td>
</tr>
{% endfor %}
</table>
<h4 class="bda-prix">Total à payer : {{ total|floatformat }}€</h4>
<br/>
<p>Ne manque pas un spectacle avec le
<a href="{% url "calendar" %}">calendrier
automatique&#8239;!</a></p>
{% else %}
<h3>Vous n'avez aucune place :(</h3>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{%block realcontent %}
<h2>Rachat d'une place</h2>
<form action="" method="post">
{% csrf_token %}
<pre>
Bonjour !
Je souhaiterais racheter ta place pour {{spectacle.title}} le {{spectacle.date}} ({{spectacle.location}}) à {{spectacle.price}}€.
Contacte-moi si tu es toujours intéressé-e !
{{user.get_full_name}} ({{user.email}})
</pre>
<input type="submit" class="btn btn-primary pull-right" value="Envoyer">
</form>
<p class="bda-prix">Note : ce mail sera envoyé à une personne au hasard revendant sa place.</p>
{%endblock%}

View file

@ -0,0 +1,9 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>Inscription à une revente</h2>
<p class="success"> Votre inscription a bien été enregistrée !</p>
<p>Le tirage au sort pour cette revente ({{spectacle}}) sera effectué le {{date}}.
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>Revente de place</h2>
<p class="success">Un mail a bien été envoyé à {{seller.get_full_name}} ({{seller.email}}), pour racheter une place pour {{spectacle.title}} !</p>
{% endblock %}

View file

@ -0,0 +1,121 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>Gestion des places que je revends</h2>
{% if resell_exists %}
<br />
<h3>Places non revendues</h3>
<form class="form-horizontal" action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
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.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Date</th>
<th data-sort="int">Prix</th>
</tr>
</thead>
<tbody>
{% for checkbox in resellform.attributions %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<div class="form-actions">
<input type="submit" class="btn btn-primary" name="resell" value="Revendre les places sélectionnées">
</div>
</form>
<hr />
{% endif %}
{% if annul_exists %}
<h3>Places en cours de revente</h3>
<form action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Vous pouvez annuler les reventes qui n'ont pas encore trouvé preneur·se.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th data-sort="int">Tirage le</th>
</tr>
</thead>
<tbody>
{% for checkbox in annulform.reventes %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<input type="submit" class="btn btn-primary" name="annul" value="Annuler les reventes sélectionnées">
</form>
<hr />
{% endif %}
{% if sold_exists %}
<h3>Places revendues</h3>
<form action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
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.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th>Vendue à</th>
</tr>
</thead>
<tbody>
{% for checkbox in soldform.reventes %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<button type="submit" class="btn btn-primary" name="transfer">Transférer</button>
<button type="submit" class="btn btn-primary" name="reinit">Réinitialiser</button>
</form>
{% endif %}
{% if not resell_exists and not annul_exists and not sold_exists %}
<p>Plus de reventes possibles !</p>
{% endif %}
<script language="JavaScript">
$(function(){
$("table.stupidtable").stupidtable();
});
$("tr").click(function() {
$(this).find("input[type=checkbox]").click()
});
$("input[type=checkbox]").click(function(e) {
e.stopPropagation();
});
</script>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>BdA-Revente</h2>
<p>Il n'y a plus de places en revente pour ce spectacle, désolé !</p>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Places disponibles immédiatement</h2>
{% if spectacles %}
<table class="table table-striped stupidtable" id="bda-shotgun">
<thead>
<tr>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th></th>
</tr>
</thead>
<tbody>
{% for spectacle in spectacles %}
<tr>
{% include "bda/forms/spectacle_label_table.html" with spectacle=spectacle %}
<td class="button"><a role="button" class="btn btn-primary" href="{% url 'bda-revente-buy' spectacle.id %}">Racheter</a>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p> Pas de places disponibles immédiatement, désolé !</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,64 @@
{% extends "base_title.html" %}
{% load staticfiles%}
{% block realcontent %}
<h2>Inscriptions pour BdA-Revente</h2>
<form action="" class="form-horizontal" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Cochez les spectacles pour lesquels vous souhaitez recevoir une
notification quand une place est disponible en revente. <br />
Lorsque vous validez vos choix, si un tirage au sort est en cours pour
un des spectacles que vous avez sélectionné, vous serez automatiquement
inscrit à ce tirage.
</div>
<br />
{% csrf_token %}
<div class="form-group">
<button type="button"
class="btn btn-primary"
onClick="select(true)">Tout sélectionner</button>
<button type="button"
class="btn btn-primary"
onClick="select(false)">Tout désélectionner</button>
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
</tr>
</thead>
<tbody>
{% for checkbox in form.spectacles %}{{ checkbox }}{% endfor %}
</tbody>
</table>
</div>
<input type="submit"
class="btn btn-primary"
value="S'inscrire pour les places sélectionnées">
</form>
<script language="JavaScript">
function select(check) {
checkboxes = document.getElementsByName("spectacles");
for(var i=0, n=checkboxes.length; i < n; i++) {
checkboxes[i].checked = check;
}
}
$(function(){
$("table.stupidtable").stupidtable();
});
$("tr").click(function() {
$(this).find("input[type=checkbox]").click()
});
$("input[type=checkbox]").click(function(e) {
e.stopPropagation();
});
</script>
{% endblock %}

View file

@ -0,0 +1,99 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>Tirages au sort de reventes</h2>
{% if annul_exists %}
<h3>Les reventes auxquelles vous êtes inscrit·e</h3>
<form class="form-horizontal" action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Voici la liste des reventes auxquelles vous êtes inscrit·e ; si vous ne souhaitez plus participer au tirage au sort vous pouvez vous en désister.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th>Vendue par</th>
<th data-sort="int">Tirage le</th>
</tr>
</thead>
<tbody>
{% for checkbox in annulform.reventes %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<div class="form-actions">
<input type="submit"
class="btn btn-primary"
name="annul"
value="Se désister des tirages sélectionnés">
</div>
</form>
{% endif %}
<hr />
{% if sub_exists %}
<h3>Tirages en cours</h3>
<form class="form-horizontal" action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Vous pouvez vous inscrire aux tirages en cours suivants.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th>Vendue par</th>
<th data-sort="int">Tirage le</th>
</tr>
</thead>
<tbody>
{% for checkbox in subform.reventes %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<div class="form-actions">
<input type="submit"
class="btn btn-primary"
name="subscribe"
value="S'inscrire aux tirages sélectionnés">
</div>
</form>
{% endif %}
{% if not annul_exists and not sub_exists %}
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Aucune revente n'est active pour le moment !
</div>
{% endif %}
<script language="JavaScript">
$(function(){
$("table.stupidtable").stupidtable();
});
$("tr").click(function() {
$(this).find("input[type=checkbox]").click()
});
$("input[type=checkbox]").click(function(e) {
e.stopPropagation();
});
</script>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Nope</h2>
{% if revente.shotgun %}
<p>Le tirage au sort de cette revente a déjà été effectué !</p>
<p>Si personne n'était intéressé, elle est maintenant disponible
<a href="{% url "bda-revente-buy" revente.attribution.spectacle.id %}">ici</a>.</p>
{% else %}
<p> Il n'est pas encore possible de s'inscrire à cette revente, réessaie dans quelque temps !</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,53 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block extra_head %}
<link type="text/css" rel="stylesheet" href="{% static "bda/css/bda.css" %}" />
{% endblock %}
{% block realcontent %}
<h2><strong>{{tirage_name}}</strong></h2>
<h3>Liste des spectacles</h3>
<table class="table table-striped table-hover etat-bda">
<thead>
<tr>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="float">Prix</th>
</tr>
</thead>
<tbody>
{% for spectacle in object_list %}
<tr class="clickable-row {% if spectacle.is_past %}spectacle-passe{% endif %}" data-href="{% url 'bda-spectacle' tirage_id spectacle.id %}">
<td><a href="{% url 'bda-spectacle' tirage_id spectacle.id %}">{{ spectacle.title }} <span style="font-size:small;" class="glyphicon glyphicon-link" aria-hidden="true"></span></a></td>
<td data-sort-value="{{ spectacle.timestamp }}"">{{ spectacle.date }}</td>
<td data-sort-value="{{ spectacle.location }}">{{ spectacle.location }}</td>
<td data-sort-value="{{ spectacle.price |stringformat:".3f" }}">
{{ spectacle.price |floatformat }}€
</td>
</tr>
{% endfor %}
</tbody>
</table>
<script type="text/javascript">
$(function(){
$("table.etat-bda").stupidtable();
});
</script>
<script>
jQuery(document).ready(function($) {
$(".clickable-row").click(function() {
window.document.location = $(this).data("href");
});
});
</script>
<h3> Exports </h3>
<ul>
<li><a href="{% url 'bda-unpaid' tirage_id %}">Mailing list impayés</a>
</ul>
{% endblock %}

View file

@ -0,0 +1,10 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Raté, le tirage ne peut pas être lancé&#8239;!</h2>
<p>Soit les inscriptions ne sont en pas encore fermées, soit le lancement du
tirage est désactivé. Si vous savez ce que vous faites, vous pouvez autoriser
le lancement du tirage dans
l'<a href="{% url "admin:index" %}">interface admin</a>.</p>
{% endblock %}

0
bda/tests/__init__.py Normal file
View file

65
bda/tests/mixins.py Normal file
View file

@ -0,0 +1,65 @@
from django.utils import timezone
from shared.tests.mixins import ViewTestCaseMixin
from ..models import CategorieSpectacle, Salle, Spectacle, Tirage
from .utils import create_user
class BdAViewTestCaseMixin(ViewTestCaseMixin):
def get_users_base(self):
return {
"bda_other": create_user(username="bda_other"),
"bda_member": create_user(username="bda_member", is_cof=True),
"bda_staff": create_user(username="bda_staff", is_cof=True, is_buro=True),
}
class BdATestHelpers:
bda_testdata = False
def setUp(self):
super().setUp()
if self.bda_testdata:
self.load_bda_testdata()
def load_bda_testdata(self):
self.tirage = Tirage.objects.create(
title="Test tirage",
appear_catalogue=True,
ouverture=timezone.now(),
fermeture=timezone.now(),
)
self.category = CategorieSpectacle.objects.create(name="Category")
self.location = Salle.objects.create(name="here")
self.show1 = Spectacle.objects.create(
title="foo",
date=timezone.now(),
location=self.location,
price=0,
slots=42,
tirage=self.tirage,
listing=False,
category=self.category,
)
self.show2 = Spectacle.objects.create(
title="bar",
date=timezone.now(),
location=self.location,
price=1,
slots=142,
tirage=self.tirage,
listing=False,
category=self.category,
)
self.show3 = Spectacle.objects.create(
title="baz",
date=timezone.now(),
location=self.location,
price=2,
slots=242,
tirage=self.tirage,
listing=False,
category=self.category,
)

100
bda/tests/test_models.py Normal file
View file

@ -0,0 +1,100 @@
from datetime import timedelta
from unittest import mock
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase
from django.utils import timezone
from bda.models import (
Attribution,
Participant,
Salle,
Spectacle,
SpectacleRevente,
Tirage,
)
User = get_user_model()
class SpectacleReventeTests(TestCase):
def setUp(self):
now = timezone.now()
self.t = Tirage.objects.create(
title="Tirage",
ouverture=now - timedelta(days=7),
fermeture=now - timedelta(days=3),
active=True,
)
self.s = Spectacle.objects.create(
title="Spectacle",
date=now + timedelta(days=20),
location=Salle.objects.create(name="Salle", address="Address"),
price=10.5,
slots=5,
tirage=self.t,
listing=False,
)
self.seller = Participant.objects.create(
user=User.objects.create(username="seller", email="seller@mail.net"),
tirage=self.t,
)
self.p1 = Participant.objects.create(
user=User.objects.create(username="part1", email="part1@mail.net"),
tirage=self.t,
)
self.p2 = Participant.objects.create(
user=User.objects.create(username="part2", email="part2@mail.net"),
tirage=self.t,
)
self.p3 = Participant.objects.create(
user=User.objects.create(username="part3", email="part3@mail.net"),
tirage=self.t,
)
self.attr = Attribution.objects.create(
participant=self.seller, spectacle=self.s
)
self.rev = SpectacleRevente.objects.create(
attribution=self.attr, seller=self.seller
)
def test_tirage(self):
revente = self.rev
wanted_by = [self.p1, self.p2, self.p3]
revente.confirmed_entry.set(wanted_by)
with mock.patch("bda.models.random.choice") as mc:
# Set winner to self.p1.
mc.return_value = self.p1
revente.tirage()
# Call to random.choice used participants in wanted_by.
mc_args, _ = mc.call_args
self.assertEqual(set(mc_args[0]), set(wanted_by))
self.assertEqual(revente.soldTo, self.p1)
self.assertTrue(revente.tirage_done)
mails = {m.to[0]: m for m in mail.outbox}
self.assertEqual(len(mails), 4)
m_seller = mails["seller@mail.net"]
self.assertListEqual(m_seller.to, ["seller@mail.net"])
self.assertListEqual(m_seller.reply_to, ["part1@mail.net"])
m_winner = mails["part1@mail.net"]
self.assertListEqual(m_winner.to, ["part1@mail.net"])
self.assertCountEqual(
[mails["part2@mail.net"].to, mails["part3@mail.net"].to],
[["part2@mail.net"], ["part3@mail.net"]],
)

79
bda/tests/test_revente.py Normal file
View file

@ -0,0 +1,79 @@
from datetime import timedelta
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone
from bda.models import (
Attribution,
CategorieSpectacle,
Participant,
Salle,
Spectacle,
SpectacleRevente,
Tirage,
)
class TestModels(TestCase):
def setUp(self):
self.tirage = Tirage.objects.create(
title="Tirage test",
appear_catalogue=True,
ouverture=timezone.now(),
fermeture=timezone.now(),
)
self.category = CategorieSpectacle.objects.create(name="Category")
self.location = Salle.objects.create(name="here")
self.spectacle_soon = Spectacle.objects.create(
title="foo",
date=timezone.now() + timedelta(days=1),
location=self.location,
price=0,
slots=42,
tirage=self.tirage,
listing=False,
category=self.category,
)
self.spectacle_later = Spectacle.objects.create(
title="bar",
date=timezone.now() + timedelta(days=30),
location=self.location,
price=0,
slots=42,
tirage=self.tirage,
listing=False,
category=self.category,
)
user_buyer = User.objects.create_user(
username="bda_buyer", password="testbuyer"
)
user_seller = User.objects.create_user(
username="bda_seller", password="testseller"
)
self.buyer = Participant.objects.create(user=user_buyer, tirage=self.tirage)
self.seller = Participant.objects.create(user=user_seller, tirage=self.tirage)
self.attr_soon = Attribution.objects.create(
participant=self.seller, spectacle=self.spectacle_soon
)
self.attr_later = Attribution.objects.create(
participant=self.seller, spectacle=self.spectacle_later
)
self.revente_soon = SpectacleRevente.objects.create(
seller=self.seller, attribution=self.attr_soon
)
self.revente_later = SpectacleRevente.objects.create(
seller=self.seller, attribution=self.attr_later
)
def test_urgent(self):
self.assertTrue(self.revente_soon.is_urgent)
self.assertFalse(self.revente_later.is_urgent)
def test_tirage(self):
self.revente_soon.confirmed_entry.add(self.buyer)
self.assertEqual(self.revente_soon.tirage(send_mails=False), self.buyer)
self.assertIsNone(self.revente_later.tirage(send_mails=False))

368
bda/tests/test_views.py Normal file
View file

@ -0,0 +1,368 @@
import json
from datetime import timedelta
from unittest import mock
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import formats, timezone
from ..models import Participant, Tirage
from .mixins import BdATestHelpers, BdAViewTestCaseMixin
User = get_user_model()
class InscriptionViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-tirage-inscription"
http_methods = ["GET", "POST"]
auth_user = "bda_member"
auth_forbidden = [None, "bda_other"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/gestion/bda/inscription/{}".format(self.tirage.id)
def test_get_opened(self):
self.tirage.ouverture = timezone.now() - timedelta(days=1)
self.tirage.fermeture = timezone.now() + timedelta(days=1)
self.tirage.save()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertFalse(resp.context["messages"])
def test_get_closed_future(self):
self.tirage.ouverture = timezone.now() + timedelta(days=1)
self.tirage.fermeture = timezone.now() + timedelta(days=2)
self.tirage.save()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertIn(
"Le tirage n'est pas encore ouvert : ouverture le {}".format(
formats.localize(timezone.template_localtime(self.tirage.ouverture))
),
[str(msg) for msg in resp.context["messages"]],
)
def test_get_closed_past(self):
self.tirage.ouverture = timezone.now() - timedelta(days=2)
self.tirage.fermeture = timezone.now() - timedelta(days=1)
self.tirage.save()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertIn(
" C'est fini : tirage au sort dans la journée !",
[str(msg) for msg in resp.context["messages"]],
)
def get_base_post_data(self):
return {
"choixspectacle_set-TOTAL_FORMS": "3",
"choixspectacle_set-INITIAL_FORMS": "0",
"choixspectacle_set-MIN_NUM_FORMS": "0",
"choixspectacle_set-MAX_NUM_FORMS": "1000",
}
base_post_data = property(get_base_post_data)
def test_post(self):
self.tirage.ouverture = timezone.now() - timedelta(days=1)
self.tirage.fermeture = timezone.now() + timedelta(days=1)
self.tirage.save()
data = dict(
self.base_post_data,
**{
"choixspectacle_set-TOTAL_FORMS": "2",
"choixspectacle_set-0-id": "",
"choixspectacle_set-0-participant": "",
"choixspectacle_set-0-spectacle": str(self.show1.pk),
"choixspectacle_set-0-double_choice": "1",
"choixspectacle_set-0-priority": "2",
"choixspectacle_set-1-id": "",
"choixspectacle_set-1-participant": "",
"choixspectacle_set-1-spectacle": str(self.show2.pk),
"choixspectacle_set-1-double_choice": "autoquit",
"choixspectacle_set-1-priority": "1",
}
)
resp = self.client.post(self.url, data)
self.assertEqual(resp.status_code, 200)
self.assertIn(
"Votre inscription a été mise à jour avec succès !",
[str(msg) for msg in resp.context["messages"]],
)
participant = Participant.objects.get(
user=self.users["bda_member"], tirage=self.tirage
)
self.assertSetEqual(
set(
participant.choixspectacle_set.values_list(
"priority", "spectacle_id", "double_choice"
)
),
{(1, self.show2.pk, "autoquit"), (2, self.show1.pk, "1")},
)
def test_post_state_changed(self):
self.tirage.ouverture = timezone.now() - timedelta(days=1)
self.tirage.fermeture = timezone.now() + timedelta(days=1)
self.tirage.save()
data = {"dbstate": "different"}
resp = self.client.post(self.url, data)
self.assertEqual(resp.status_code, 200)
self.assertIn(
"Impossible d'enregistrer vos modifications : vous avez apporté d'autres "
"modifications entre temps.",
[str(msg) for msg in resp.context["messages"]],
)
class PlacesViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-places-attribuees"
auth_user = "bda_member"
auth_forbidden = [None, "bda_other"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/gestion/bda/places/{}".format(self.tirage.id)
class EtatPlacesViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-etat-places"
auth_user = "bda_member"
auth_forbidden = [None, "bda_other"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/gestion/bda/etat-places/{}".format(self.tirage.id)
class TirageViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-tirage"
http_methods = ["GET", "POST"]
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/gestion/bda/tirage/{}".format(self.tirage.id)
def test_perform_tirage_disabled(self):
# Cannot be performed if disabled
self.tirage.enable_do_tirage = False
self.tirage.save()
resp = self.client.get(self.url)
self.assertTemplateUsed(resp, "tirage-failed.html")
def test_perform_tirage_opened_registrations(self):
# Cannot be performed if registrations are still open
self.tirage.enable_do_tirage = True
self.tirage.fermeture = timezone.now() + timedelta(seconds=3600)
self.tirage.save()
resp = self.client.get(self.url)
self.assertTemplateUsed(resp, "tirage-failed.html")
def test_perform_tirage(self):
# Otherwise, perform the tirage
self.tirage.enable_do_tirage = True
self.tirage.fermeture = timezone.now()
self.tirage.save()
resp = self.client.get(self.url)
self.assertTemplateNotUsed(resp, "tirage-failed.html")
class SpectacleListViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-liste-spectacles"
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/gestion/bda/spectacles/{}".format(self.tirage.id)
class SpectacleViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-spectacle"
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id, "spectacle_id": self.show1.id}
@property
def url_expected(self):
return "/gestion/bda/spectacles/{}/{}".format(self.tirage.id, self.show1.id)
class UnpaidViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-unpaid"
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/gestion/bda/spectacles/unpaid/{}".format(self.tirage.id)
class SendRemindersViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-rappels"
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"spectacle_id": self.show1.id}
@property
def url_expected(self):
return "/gestion/bda/mails-rappel/{}".format(self.show1.id)
def test_post(self):
resp = self.client.post(self.url)
self.assertEqual(200, resp.status_code)
# TODO: check that emails are sent
class CatalogueViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
auth_user = None
auth_forbidden = []
bda_testdata = True
def test_api_list(self):
url_list = "/gestion/bda/catalogue/list"
resp = self.client.get(url_list)
self.assertJSONEqual(
resp.content.decode("utf-8"),
[{"id": self.tirage.id, "title": self.tirage.title}],
)
def test_api_details(self):
url_details = "/gestion/bda/catalogue/details?id={}".format(self.tirage.id)
resp = self.client.get(url_details)
self.assertJSONEqual(
resp.content.decode("utf-8"),
{
"categories": [{"id": self.category.id, "name": self.category.name}],
"locations": [{"id": self.location.id, "name": self.location.name}],
},
)
def test_api_descriptions(self):
url_descriptions = "/gestion/bda/catalogue/descriptions?id={}".format(
self.tirage.id
)
resp = self.client.get(url_descriptions)
raw = resp.content.decode("utf-8")
try:
results = json.loads(raw)
except ValueError:
self.fail("Not valid JSON: {}".format(raw))
self.assertEqual(len(results), 3)
self.assertEqual(
{(s["title"], s["price"], s["slots"]) for s in results},
{("foo", 0, 42), ("bar", 1, 142), ("baz", 2, 242)},
)
# ----- BdA Revente --------------------------------------- #
def make_participant(name: str, tirage: Tirage) -> User:
user = User.objects.create_user(username=name, password=name)
user.profile.is_cof = True
user.profile.save()
Participant.objects.create(user=user, tirage=tirage)
return user
class TestReventeManageTest(TestCase):
def setUp(self):
self.tirage = Tirage.objects.create(
title="tirage1",
ouverture=timezone.now(),
fermeture=timezone.now() + timedelta(days=90),
)
self.user = make_participant("toto", self.tirage)
self.url = reverse("bda-revente-manage", args=[self.tirage.id])
# Signals handlers on login/logout send messages.
# Due to the way the Django' test Client performs login, this raise an
# error. As workaround, we mock the Django' messages module.
patcher_messages = mock.patch("gestioncof.signals.messages")
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
def test_can_get(self):
client = Client()
client.force_login(
self.user, backend="django.contrib.auth.backends.ModelBackend"
)
r = client.get(self.url)
self.assertEqual(r.status_code, 200)
class TestBdaRevente:
pass
# TODO

36
bda/tests/utils.py Normal file
View file

@ -0,0 +1,36 @@
from datetime import timedelta
from django.contrib.auth.models import User
from django.utils import timezone
from ..models import CategorieSpectacle, Salle, Spectacle, Tirage
def create_user(username, is_cof=False, is_buro=False):
user = User.objects.create_user(username=username, password=username)
user.profile.is_cof = is_cof
user.profile.is_buro = is_buro
user.profile.save()
return user
def user_is_cof(user):
return (user is not None) and user.profile.is_cof
def user_is_staff(user):
return (user is not None) and user.profile.is_buro
def create_spectacle(**kwargs):
defaults = {
"title": "Title",
"category": CategorieSpectacle.objects.first(),
"date": (timezone.now() + timedelta(days=7)).date(),
"location": Salle.objects.first(),
"price": 10.0,
"slots": 20,
"tirage": Tirage.objects.first(),
"listing": False,
}
return Spectacle.objects.create(**dict(defaults, **kwargs))

74
bda/urls.py Normal file
View file

@ -0,0 +1,74 @@
from django.conf.urls import url
from bda import views
from bda.views import SpectacleListView
from gestioncof.decorators import buro_required
urlpatterns = [
url(
r"^inscription/(?P<tirage_id>\d+)$",
views.inscription,
name="bda-tirage-inscription",
),
url(r"^places/(?P<tirage_id>\d+)$", views.places, name="bda-places-attribuees"),
url(r"^etat-places/(?P<tirage_id>\d+)$", views.etat_places, name="bda-etat-places"),
url(r"^tirage/(?P<tirage_id>\d+)$", views.tirage, name="bda-tirage"),
url(
r"^spectacles/(?P<tirage_id>\d+)$",
buro_required(SpectacleListView.as_view()),
name="bda-liste-spectacles",
),
url(
r"^spectacles/(?P<tirage_id>\d+)/(?P<spectacle_id>\d+)$",
views.spectacle,
name="bda-spectacle",
),
url(
r"^spectacles/unpaid/(?P<tirage_id>\d+)$",
views.UnpaidParticipants.as_view(),
name="bda-unpaid",
),
url(
r"^spectacles/autocomplete$",
views.spectacle_autocomplete,
name="bda-spectacle-autocomplete",
),
url(
r"^participants/autocomplete$",
views.participant_autocomplete,
name="bda-participant-autocomplete",
),
# Urls BdA-Revente
url(
r"^revente/(?P<tirage_id>\d+)/manage$",
views.revente_manage,
name="bda-revente-manage",
),
url(
r"^revente/(?P<tirage_id>\d+)/subscribe$",
views.revente_subscribe,
name="bda-revente-subscribe",
),
url(
r"^revente/(?P<tirage_id>\d+)/tirages$",
views.revente_tirages,
name="bda-revente-tirages",
),
url(
r"^revente/(?P<spectacle_id>\d+)/buy$",
views.revente_buy,
name="bda-revente-buy",
),
url(
r"^revente/(?P<revente_id>\d+)/confirm$",
views.revente_confirm,
name="bda-revente-confirm",
),
url(
r"^revente/(?P<tirage_id>\d+)/shotgun$",
views.revente_shotgun,
name="bda-revente-shotgun",
),
url(r"^mails-rappel/(?P<spectacle_id>\d+)$", views.send_rappel, name="bda-rappels"),
url(r"^catalogue/(?P<request_type>[a-z]+)$", views.catalogue, name="bda-catalogue"),
]

911
bda/views.py Normal file
View file

@ -0,0 +1,911 @@
import hashlib
import json
import random
import time
from collections import defaultdict
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.core.mail import send_mail, send_mass_mail
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 import loader
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 shared.views 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 le
client demande le formulaire et le moment 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)
participant.accepte_charte = True
participant.save()
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,
"charte": participant.accepte_charte,
},
)
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.annotate_paid().get_or_create(
user=request.user, tirage=tirage
)
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():
mails = []
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,
}
mails.append(
(
"BdA-Revente : {}".format(attribution.spectacle),
loader.render_to_string(
"bda/mails/revente-seller.txt", context=context
),
settings.MAIL_DATA["revente"]["FROM"],
[participant.user.email],
)
)
send_mass_mail(mails)
# 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 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.set(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("<li>{!s}</li>".format, inscrit_revente)
msg = (
"Vous avez été inscrit·e à des reventes en cours pour les spectacles "
"<ul>{:s}</ul>".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_mail(
"BdA-Revente : {}".format(spectacle.title),
loader.render_to_string(
"bda/mails/revente-shotgun-seller.txt", context=context
),
request.user.email,
[revente.seller.user.email],
)
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": attrib.paid,
"nb_places": 1,
}
if participant.id in participants:
participants[participant.id]["nb_places"] += 1
participants[participant.id]["given"] += attrib.given
participants[participant.id]["paid"] &= attrib.paid
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
class UnpaidParticipants(BuroRequiredMixin, ListView):
context_object_name = "unpaid"
template_name = "bda-unpaid.html"
def get_queryset(self):
return (
Participant.objects.annotate_paid()
.filter(tirage__id=self.kwargs["tirage_id"], paid=False)
.select_related("user")
)
@buro_required
def send_rappel(request, spectacle_id):
show = get_object_or_404(Spectacle, id=spectacle_id)
# Mails d'exemples
subject = show.title
body_mail_1place = loader.render_to_string(
"bda/mails/rappel.txt",
context={"member": request.user, "show": show, "nb_attr": 1},
)
body_mail_2places = loader.render_to_string(
"bda/mails/rappel.txt",
context={"member": request.user, "show": show, "nb_attr": 2},
)
# Contexte
ctxt = {
"show": show,
"exemple_mail_1place": (subject, body_mail_1place),
"exemple_mail_2places": (subject, body_mail_2places),
}
# 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 <int>")
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())

1
bds/__init__.py Normal file
View file

@ -0,0 +1 @@
default_app_config = "bds.apps.BdsConfig"

5
bds/admin.py Normal file
View file

@ -0,0 +1,5 @@
from django.contrib import admin
from bds.models import BDSProfile
admin.site.register(BDSProfile)

29
bds/apps.py Normal file
View file

@ -0,0 +1,29 @@
from django import apps as global_apps
from django.apps import AppConfig
from django.db.models import Q
from django.db.models.signals import post_migrate
def bds_group_perms(app_config, apps=global_apps, **kwargs):
try:
Permission = apps.get_model("auth", "Permission")
Group = apps.get_model("auth", "Group")
group = Group.objects.get(name="Burô du BDS")
perms = Permission.objects.filter(
Q(content_type__app_label="bds")
| Q(content_type__app_label="auth") & Q(content_type__model="user")
)
group.permissions.set(perms)
group.save()
except (LookupError, Group.DoesNotExist):
return
class BdsConfig(AppConfig):
name = "bds"
verbose_name = "Gestion des adhérent·e·s du BDS"
def ready(self):
post_migrate.connect(bds_group_perms, sender=self)

63
bds/autocomplete.py Normal file
View file

@ -0,0 +1,63 @@
from urllib.parse import urlencode
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from shared import autocomplete
User = get_user_model()
class BDSMemberSearch(autocomplete.ModelSearch):
model = User
search_fields = ["username", "first_name", "last_name"]
verbose_name = _("Membres du BDS")
def get_queryset_filter(self, *args, **kwargs):
qset_filter = super().get_queryset_filter(*args, **kwargs)
qset_filter &= Q(bds__is_member=True)
return qset_filter
def result_uuid(self, user):
return user.username
def result_link(self, user):
return reverse("bds:user.update", args=(user.pk,))
class BDSOthersSearch(autocomplete.ModelSearch):
model = User
search_fields = ["username", "first_name", "last_name"]
verbose_name = _("Non-membres du BDS")
def get_queryset_filter(self, *args, **kwargs):
qset_filter = super().get_queryset_filter(*args, **kwargs)
qset_filter &= Q(bds__isnull=True) | Q(bds__is_member=False)
return qset_filter
def result_uuid(self, user):
return user.username
def result_link(self, user):
return reverse("bds:user.update", args=(user.pk,))
class BDSLDAPSearch(autocomplete.LDAPSearch):
def result_link(self, clipper):
url = reverse("bds:user.create.fromclipper", args=(clipper.clipper,))
get = {"fullname": clipper.fullname, "mail": clipper.mail}
return "{}?{}".format(url, urlencode(get))
class BDSSearch(autocomplete.Compose):
search_units = [
("members", BDSMemberSearch()),
("others", BDSOthersSearch()),
("clippers", BDSLDAPSearch()),
]
bds_search = BDSSearch()

39
bds/forms.py Normal file
View file

@ -0,0 +1,39 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm
from django.utils.translation import gettext_lazy as _
from bds.models import BDSProfile
User = get_user_model()
class UserForm(forms.ModelForm):
is_buro = forms.BooleanField(label=_("Membre du Burô"), required=False)
class Meta:
model = User
fields = ["email", "first_name", "last_name"]
class UserFromClipperForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["username"].disabled = True
class Meta:
model = User
fields = ["username", "email", "first_name", "last_name"]
class UserFromScratchForm(UserCreationForm):
class Meta:
model = User
fields = ["username", "email", "first_name", "last_name"]
class ProfileForm(forms.ModelForm):
class Meta:
model = BDSProfile
exclude = ["user"]
widgets = {"birthdate": forms.DateInput(attrs={"type": "date"})}

View file

@ -0,0 +1,142 @@
# Generated by Django 2.2 on 2019-07-17 12:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import bds.models
class Migration(migrations.Migration):
initial = True
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name="BDSProfile",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"phone",
models.CharField(
blank=True, max_length=20, verbose_name="téléphone"
),
),
(
"occupation",
models.CharField(
choices=[
("EXT", "Extérieur"),
("1A", "1A"),
("2A", "2A"),
("3A", "3A"),
("4A", "4A"),
("MAG", "Magistérien"),
("ARC", "Archicube"),
("DOC", "Doctorant"),
("CST", "CST"),
("PER", "Personnel ENS"),
],
default="1A",
max_length=3,
verbose_name="occupation",
),
),
(
"departement",
models.CharField(
blank=True, max_length=50, verbose_name="département"
),
),
(
"birthdate",
models.DateField(
blank=True, null=True, verbose_name="date de naissance"
),
),
(
"mails_bds",
models.BooleanField(
default=False, verbose_name="recevoir les mails du BDS"
),
),
(
"is_buro",
models.BooleanField(
default=False, verbose_name="membre du Burô du BDS"
),
),
(
"has_certificate",
models.BooleanField(
default=False, verbose_name="certificat médical"
),
),
(
"certificate_file",
models.FileField(
blank=True,
upload_to=bds.models.BDSProfile.get_certificate_filename,
verbose_name="fichier de certificat médical",
),
),
(
"ASPSL_number",
models.CharField(
blank=True,
max_length=50,
null=True,
verbose_name="numéro AS PSL",
),
),
(
"FFSU_number",
models.CharField(
blank=True, max_length=50, null=True, verbose_name="numéro FFSU"
),
),
(
"cotisation_period",
models.CharField(
choices=[
("ANN", "Année"),
("SE1", "Premier semestre"),
("SE2", "Deuxième semestre"),
("NO", "Aucune"),
],
default="NO",
max_length=3,
verbose_name="inscription",
),
),
(
"registration_date",
models.DateField(
auto_now_add=True, verbose_name="date d'inscription"
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="bds",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Profil BDS",
"verbose_name_plural": "Profils BDS",
},
)
]

View file

@ -0,0 +1,17 @@
# Generated by Django 2.2 on 2019-07-17 14:56
from django.db import migrations
def create_bds_buro_group(apps, schema_editor):
Group = apps.get_model("auth", "Group")
Group.objects.get_or_create(name="Burô du BDS")
class Migration(migrations.Migration):
dependencies = [("bds", "0001_initial")]
operations = [
migrations.RunPython(create_bds_buro_group, migrations.RunPython.noop)
]

View file

@ -0,0 +1,25 @@
# Generated by Django 2.2.8 on 2019-12-20 22:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bds", "0002_bds_group"),
]
operations = [
migrations.AlterModelOptions(
name="bdsprofile",
options={
"permissions": (("is_team", "est membre du burô"),),
"verbose_name": "Profil BDS",
"verbose_name_plural": "Profils BDS",
},
),
migrations.RemoveField(
model_name="bdsprofile",
name="is_buro",
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 2.2.8 on 2019-12-22 10:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bds", "0003_staff_permission"),
]
operations = [
migrations.AddField(
model_name="bdsprofile",
name="cotisation_type",
field=models.CharField(
choices=[
("ETU", "Étudiant"),
("NOR", "Normalien"),
("EXT", "Extérieur"),
("ARC", "Archicube"),
],
default="Normalien",
max_length=9,
verbose_name="type de cotisation",
),
preserve_default=False,
),
migrations.AddField(
model_name="bdsprofile",
name="is_member",
field=models.BooleanField(default=False, verbose_name="adhérent⋅e du BDS"),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 2.2.14 on 2020-07-27 20:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bds", "0004_is_member_cotiz_type"),
]
operations = [
migrations.RemoveField(
model_name="bdsprofile",
name="certificate_file",
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 2.2.12 on 2020-08-28 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bds", "0005_remove_bdsprofile_certificate_file"),
]
operations = [
migrations.AddField(
model_name="bdsprofile",
name="comments",
field=models.TextField(
blank=True,
help_text="Attention : l'utilisateur·ice dispose d'un droit d'accès"
" aux données le/la concernant, dont le contenu de ce champ !",
verbose_name="commentaires",
),
),
]

View file

122
bds/mixins.py Normal file
View file

@ -0,0 +1,122 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.views.generic.base import ContextMixin, TemplateResponseMixin, View
class StaffRequiredMixin(PermissionRequiredMixin):
permission_required = "bds.is_team"
class MultipleFormMixin(ContextMixin):
"""Mixin pour gérer plusieurs formulaires dans la même vue.
Le fonctionnement est relativement identique à celui de
FormMixin, dont la documentation est disponible ici :
https://docs.djangoproject.com/en/3.0/ref/class-based-views/mixins-editing/
Les principales différences sont :
- au lieu de form_class, il faut donner comme attribut un dict de la forme
{<form_name>: <form_class>}, avec tous les formulaires à instancier. On
peut aussi redéfinir `get_form_classes`
- les données initiales se récupèrent pour chaque form via l'attribut
`<form_name>_initial` ou la fonction `get_<form_name>_initial`. De même,
si certaines forms sont des `ModelForm`s, on peut définir la fonction
`get_<form_name>_instance`.
- chaque form a un préfixe rajouté, par défaut <form_name>, mais qui peut
être customisé via `prefixes` ou `get_prefixes`.
"""
form_classes = {}
prefixes = {}
initial = {}
success_url = None
def get_form_classes(self):
return self.form_classes
def get_initial(self, form_name):
initial_attr = "%s_initial" % form_name
initial_method = "get_%s_initial" % form_name
initial_method = getattr(self, initial_method, None)
if hasattr(self, initial_attr):
return getattr(self, initial_attr)
elif callable(initial_method):
return initial_method()
else:
return self.initial.copy()
def get_prefix(self, form_name):
return self.prefixes.get(form_name, form_name)
def get_instance(self, form_name):
# Au cas où certaines des forms soient des ModelForms
instance_method = "get_%s_instance" % form_name
instance_method = getattr(self, instance_method, None)
if callable(instance_method):
return instance_method()
else:
return None
def get_form_kwargs(self, form_name):
kwargs = {
"initial": self.get_initial(form_name),
"prefix": self.get_prefix(form_name),
"instance": self.get_instance(form_name),
}
if self.request.method in ("POST", "PUT"):
kwargs.update({"data": self.request.POST, "files": self.request.FILES})
return kwargs
def get_forms(self):
form_classes = self.get_form_classes()
return {
form_name: form_class(**self.get_form_kwargs(form_name))
for form_name, form_class in form_classes.items()
}
def get_success_url(self):
if not self.success_url:
raise ImproperlyConfigured("No URL to redirect to. Provide a success_url.")
return str(self.success_url)
def form_valid(self, forms):
# on garde le nom form_valid pour l'interface avec SuccessMessageMixin
return HttpResponseRedirect(self.get_success_url())
def form_invalid(self, forms):
"""If the form is invalid, render the invalid form."""
return self.render_to_response(self.get_context_data(forms=forms))
class ProcessMultipleFormView(View):
"""Équivalent de `ProcessFormView` pour plusieurs forms.
Note : il faut que *tous* les formulaires soient valides pour
qu'ils soient sauvegardés !
"""
def get(self, request, *args, **kwargs):
forms = self.get_forms()
return self.render_to_response(self.get_context_data(forms=forms))
def post(self, request, *args, **kwargs):
forms = self.get_forms()
if all(form.is_valid() for form in forms.values()):
return self.form_valid(forms)
else:
return self.form_invalid(forms)
class BaseMultipleFormView(MultipleFormMixin, ProcessMultipleFormView):
pass
class MultipleFormView(TemplateResponseMixin, BaseMultipleFormView):
pass

102
bds/models.py Normal file
View file

@ -0,0 +1,102 @@
from datetime import date
from os.path import splitext
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
from shared.utils import choices_length
User = get_user_model()
class BDSProfile(models.Model):
OCCUPATION_CHOICES = (
("EXT", "Extérieur"),
("1A", "1A"),
("2A", "2A"),
("3A", "3A"),
("4A", "4A"),
("MAG", "Magistérien"),
("ARC", "Archicube"),
("DOC", "Doctorant"),
("CST", "CST"),
("PER", "Personnel ENS"),
)
TYPE_COTIZ_CHOICES = (
("ETU", "Étudiant"),
("NOR", "Normalien"),
("EXT", "Extérieur"),
("ARC", "Archicube"),
)
COTIZ_DURATION_CHOICES = (
("ANN", "Année"),
("SE1", "Premier semestre"),
("SE2", "Deuxième semestre"),
("NO", "Aucune"),
)
def get_certificate_filename(instance, filename):
_, ext = splitext(filename) # récupère l'extension du fichier
year = str(date.now().year)
return "certifs/{username}-{year}.{ext}".format(
username=instance.user.username, year=year, ext=ext
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="bds")
phone = models.CharField(_("téléphone"), max_length=20, blank=True)
occupation = models.CharField(
_("occupation"),
default="1A",
choices=OCCUPATION_CHOICES,
max_length=choices_length(OCCUPATION_CHOICES),
)
departement = models.CharField(_("département"), max_length=50, blank=True)
birthdate = models.DateField(
auto_now_add=False,
auto_now=False,
verbose_name=_("date de naissance"),
blank=True,
null=True,
)
is_member = models.BooleanField(_("adhérent⋅e du BDS"), default=False)
mails_bds = models.BooleanField(_("recevoir les mails du BDS"), default=False)
has_certificate = models.BooleanField(_("certificat médical"), default=False)
ASPSL_number = models.CharField(
_("numéro AS PSL"), max_length=50, blank=True, null=True
)
FFSU_number = models.CharField(
_("numéro FFSU"), max_length=50, blank=True, null=True
)
cotisation_period = models.CharField(
_("inscription"), default="NO", choices=COTIZ_DURATION_CHOICES, max_length=3
)
registration_date = models.DateField(
auto_now_add=True, verbose_name=_("date d'inscription")
)
cotisation_type = models.CharField(
_("type de cotisation"), choices=TYPE_COTIZ_CHOICES, max_length=9
)
comments = models.TextField(
_("commentaires"),
blank=True,
help_text=_(
"Attention : l'utilisateur·ice dispose d'un droit d'accès aux données "
"le/la concernant, dont le contenu de ce champ !"
),
)
class Meta:
verbose_name = _("Profil BDS")
verbose_name_plural = _("Profils BDS")
permissions = (("is_team", _("est membre du burô")),)
def __str__(self):
return self.user.username

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more