diff --git a/CHANGELOG b/CHANGELOG index 138789e8..66a06dc0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +- Le champ de paiement BdA se fait au niveau des attributions - On peut supprimer des comptes et des articles K-Fêt - Passage à Django2 - Dev : on peut désactiver la barre de debug avec une variable shell diff --git a/bda/admin.py b/bda/admin.py index b32144f1..7f626c7a 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -4,7 +4,7 @@ from custommail.shortcuts import send_mass_custom_mail from dal.autocomplete import ModelSelect2 from django import forms from django.contrib import admin -from django.db.models import Count, Sum +from django.db.models import Count, Q, Sum from django.template.defaultfilters import pluralize from django.utils import timezone @@ -81,11 +81,13 @@ 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): @@ -96,12 +98,31 @@ class ParticipantAdminForm(forms.ModelForm): ) +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 Participant.objects.annotate( - nb_places=Count("attributions"), total=Sum("attributions__price") + 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): @@ -110,6 +131,13 @@ class ParticipantAdmin(ReadOnlyMixin, admin.ModelAdmin): 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: @@ -118,14 +146,25 @@ class ParticipantAdmin(ReadOnlyMixin, admin.ModelAdmin): return "0 €" total.admin_order_field = "total" - total.short_description = "Total à payer" - list_display = ("user", "nb_places", "total", "paid", "paymenttype", "tirage") - list_filter = ("paid", "tirage") + 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",) + readonly_fields = ("total", "paid") readonly_fields_update = ("user", "tirage") form = ParticipantAdminForm @@ -183,11 +222,7 @@ class AttributionAdminForm(forms.ModelForm): class AttributionAdmin(ReadOnlyMixin, admin.ModelAdmin): - def paid(self, obj): - return obj.participant.paid - paid.short_description = "A payé" - paid.boolean = True list_display = ("id", "spectacle", "participant", "given", "paid") search_fields = ( "spectacle__title", diff --git a/bda/migrations/0014_attribution_paid_field.py b/bda/migrations/0014_attribution_paid_field.py new file mode 100644 index 00000000..b5bb6208 --- /dev/null +++ b/bda/migrations/0014_attribution_paid_field.py @@ -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", + ), + ), + ] diff --git a/bda/migrations/0015_move_bda_payment.py b/bda/migrations/0015_move_bda_payment.py new file mode 100644 index 00000000..93f121a1 --- /dev/null +++ b/bda/migrations/0015_move_bda_payment.py @@ -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) + ] diff --git a/bda/migrations/0016_delete_participant_paid.py b/bda/migrations/0016_delete_participant_paid.py new file mode 100644 index 00000000..f59d1eb9 --- /dev/null +++ b/bda/migrations/0016_delete_participant_paid.py @@ -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"), + ] diff --git a/bda/models.py b/bda/models.py index 9ac38a41..1a072eb7 100644 --- a/bda/models.py +++ b/bda/models.py @@ -9,7 +9,7 @@ from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.core import mail from django.db import models -from django.db.models import Count +from django.db.models import Count, Exists from django.utils import formats, timezone @@ -151,6 +151,41 @@ PAYMENT_TYPES = ( ) +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( @@ -159,15 +194,13 @@ class Participant(models.Model): attributions = models.ManyToManyField( Spectacle, through="Attribution", related_name="attributed_to" ) - paid = models.BooleanField("A payé", default=False) - paymenttype = models.CharField( - "Moyen de paiement", max_length=6, choices=PAYMENT_TYPES, blank=True - ) tirage = models.ForeignKey(Tirage, on_delete=models.CASCADE) choicesrevente = models.ManyToManyField( Spectacle, related_name="subscribed", blank=True ) + objects = ParticipantPaidQueryset.as_manager() + def __str__(self): return "%s - %s" % (self.user, self.tirage.title) @@ -212,21 +245,6 @@ class ChoixSpectacle(models.Model): verbose_name_plural = "voeux" -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) - - def __str__(self): - return "%s -- %s, %s" % ( - self.participant.user, - self.spectacle.title, - self.spectacle.date, - ) - - class SpectacleRevente(models.Model): attribution = models.OneToOneField( Attribution, on_delete=models.CASCADE, related_name="revente" diff --git a/bda/urls.py b/bda/urls.py index cefde4a2..5b452362 100644 --- a/bda/urls.py +++ b/bda/urls.py @@ -23,7 +23,11 @@ urlpatterns = [ views.spectacle, name="bda-spectacle", ), - url(r"^spectacles/unpaid/(?P\d+)$", views.unpaid, name="bda-unpaid"), + url( + r"^spectacles/unpaid/(?P\d+)$", + views.UnpaidParticipants.as_view(), + name="bda-unpaid", + ), url( r"^spectacles/autocomplete$", views.spectacle_autocomplete, diff --git a/bda/views.py b/bda/views.py index c0f4a079..6a0e7ec7 100644 --- a/bda/views.py +++ b/bda/views.py @@ -41,7 +41,7 @@ from bda.models import ( SpectacleRevente, Tirage, ) -from gestioncof.decorators import buro_required, cof_required +from gestioncof.decorators import BuroRequiredMixin, buro_required, cof_required from utils.views.autocomplete import Select2QuerySetView @@ -378,7 +378,7 @@ def revente_manage(request, tirage_id): - Annulation d'une revente après que le tirage a eu lieu """ tirage = get_object_or_404(Tirage, id=tirage_id) - participant, created = Participant.objects.get_or_create( + participant, created = Participant.annotate_paid().get_or_create( user=request.user, tirage=tirage ) @@ -695,12 +695,13 @@ def spectacle(request, tirage_id, spectacle_id): "username": participant.user.username, "email": participant.user.email, "given": int(attrib.given), - "paid": participant.paid, + "paid": True, "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 @@ -728,15 +729,16 @@ class SpectacleListView(ListView): return context -@buro_required -def unpaid(request, tirage_id): - tirage = get_object_or_404(Tirage, id=tirage_id) - unpaid = ( - tirage.participant_set.annotate(nb_attributions=Count("attribution")) - .filter(paid=False, nb_attributions__gt=0) - .select_related("user") - ) - return render(request, "bda-unpaid.html", {"unpaid": unpaid}) +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 diff --git a/gestioncof/decorators.py b/gestioncof/decorators.py index 37d93c7f..28a67331 100644 --- a/gestioncof/decorators.py +++ b/gestioncof/decorators.py @@ -1,9 +1,12 @@ +import logging from functools import wraps -from django.contrib.auth.decorators import login_required, user_passes_test -from django.core.exceptions import PermissionDenied +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import PermissionRequiredMixin from django.shortcuts import render +logger = logging.getLogger(__name__) + def cof_required(view_func): """Décorateur qui vérifie que l'utilisateur est connecté et membre du COF. @@ -53,3 +56,26 @@ def buro_required(view_func): return render(request, "buro-denied.html", status=403) return login_required(_wrapped_view) + + +class CofRequiredMixin(PermissionRequiredMixin): + def has_permission(self): + if not self.request.user.is_authenticated: + return False + try: + return self.request.user.profile.is_cof + except AttributeError: + return False + + +class BuroRequiredMixin(PermissionRequiredMixin): + def has_permission(self): + if not self.request.user.is_authenticated: + return False + try: + return self.request.user.profile.is_buro + except AttributeError: + logger.error( + "L'utilisateur %s n'a pas de profil !", self.request.user.username + ) + return False