From dbd017f680c38ef8c9a567658c6eb138ff81e82d Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Wed, 21 Nov 2018 16:14:52 +0100 Subject: [PATCH 01/15] Prettify revente/subscribe --- bda/forms.py | 27 +++++++++++- .../bda/forms/spectacle_checkbox_table.html | 4 ++ .../bda/forms/spectacle_label_table.html | 4 ++ bda/templates/bda/revente/subscribe.html | 41 ++++++++++++------- bda/views.py | 6 +-- 5 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 bda/templates/bda/forms/spectacle_checkbox_table.html create mode 100644 bda/templates/bda/forms/spectacle_label_table.html diff --git a/bda/forms.py b/bda/forms.py index 94a52128..8e3038a6 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -1,5 +1,6 @@ from django import forms from django.forms.models import BaseInlineFormSet +from django.template import loader from django.utils import timezone from bda.models import Attribution, Spectacle, SpectacleRevente @@ -110,11 +111,32 @@ class AnnulForm(forms.Form): ) +class TemplateLabelField(forms.ModelMultipleChoiceField): + """ + Offers an option to render a label with Django template rendering + """ + + def __init__(self, template_name=None, context_object_name="obj", *args, **kwargs): + super().__init__(*args, **kwargs) + self.template_name = template_name + self.context_object_name = context_object_name + + def label_from_instance(self, obj): + if self.template_name is None: + return super().label_from_instance(obj) + else: + return loader.render_to_string( + self.template_name, context={self.context_object_name: obj} + ) + + class InscriptionReventeForm(forms.Form): - spectacles = forms.ModelMultipleChoiceField( + spectacles = TemplateLabelField( queryset=Spectacle.objects.none(), widget=forms.CheckboxSelectMultiple, required=False, + template_name="bda/forms/spectacle_label_table.html", + context_object_name="spectacle", ) def __init__(self, tirage, *args, **kwargs): @@ -122,6 +144,9 @@ class InscriptionReventeForm(forms.Form): self.fields["spectacles"].queryset = tirage.spectacle_set.select_related( "location" ).filter(date__gte=timezone.now()) + self.fields[ + "spectacles" + ].widget.option_template_name = "bda/forms/spectacle_checkbox_table.html" class ReventeTirageAnnulForm(forms.Form): diff --git a/bda/templates/bda/forms/spectacle_checkbox_table.html b/bda/templates/bda/forms/spectacle_checkbox_table.html new file mode 100644 index 00000000..e13a9c9d --- /dev/null +++ b/bda/templates/bda/forms/spectacle_checkbox_table.html @@ -0,0 +1,4 @@ + + + {{ widget.label }} + \ No newline at end of file diff --git a/bda/templates/bda/forms/spectacle_label_table.html b/bda/templates/bda/forms/spectacle_label_table.html new file mode 100644 index 00000000..a2c4181f --- /dev/null +++ b/bda/templates/bda/forms/spectacle_label_table.html @@ -0,0 +1,4 @@ +{{ spectacle.title }} +{{ spectacle.date }} +{{ spectacle.location }} +{{ spectacle.price |floatformat }}€ \ No newline at end of file diff --git a/bda/templates/bda/revente/subscribe.html b/bda/templates/bda/revente/subscribe.html index 9a193908..8a9545eb 100644 --- a/bda/templates/bda/revente/subscribe.html +++ b/bda/templates/bda/revente/subscribe.html @@ -1,12 +1,12 @@ {% extends "base_title.html" %} -{% load bootstrap %} +{% load staticfiles%} {% block realcontent %}

Inscriptions pour BdA-Revente

- Cochez les spectacles pour lesquels vous souhaitez recevoir un + Cochez les spectacles pour lesquels vous souhaitez recevoir une notification quand une place est disponible en revente.
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 @@ -21,26 +21,37 @@ + + + + + + + + + + + + {% for checkbox in form.spectacles %}{{ checkbox }}{% endfor %} + +
TitreDateLieuPrix
-
-
    - {% for checkbox in form.spectacles %} -
  • {{ checkbox }}
  • - {% endfor %} -
-
+ {% endblock %} diff --git a/bda/views.py b/bda/views.py index 050d6851..7e8d9167 100644 --- a/bda/views.py +++ b/bda/views.py @@ -569,18 +569,18 @@ def revente_subscribe(request, tirage_id): ) # Messages if success: - messages.success(request, "Ton inscription a bien été prise en compte") + 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érifie les places " + "ces spectacles, vérifiez les places " "disponibles sans tirage !", ) if inscrit_revente: shows = map("
  • {!s}
  • ".format, inscrit_revente) msg = ( - "Tu as été inscrit à des reventes en cours pour les spectacles " + "Vous avez été inscrit·e à des reventes en cours pour les spectacles " "".format("\n".join(shows)) ) messages.info(request, msg, extra_tags="safe") From 625825cf3f13f6c263ca0e37ad5dae5b8fbae409 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Fri, 7 Dec 2018 17:04:18 +0100 Subject: [PATCH 02/15] =?UTF-8?q?Am=C3=A9liore=20`TemplateLabelField`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rajouter une option pour `option_template_name` et `context_object_name` dans la classe, et documente mieux. Répercute ces changements dans `InscriptionReventeForm`. --- bda/forms.py | 27 ++++++++++++------- ...heckbox_table.html => checkbox_table.html} | 0 2 files changed, 18 insertions(+), 9 deletions(-) rename bda/templates/bda/forms/{spectacle_checkbox_table.html => checkbox_table.html} (100%) diff --git a/bda/forms.py b/bda/forms.py index 8e3038a6..8230056d 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -113,20 +113,31 @@ class AnnulForm(forms.Form): class TemplateLabelField(forms.ModelMultipleChoiceField): """ - Offers an option to render a label with Django template rendering + 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, template_name=None, context_object_name="obj", *args, **kwargs): + def __init__( + self, + label_template_name=None, + context_object_name="obj", + option_template_name=None, + *args, + **kwargs + ): super().__init__(*args, **kwargs) - self.template_name = template_name + 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.template_name is None: + if self.label_template_name is None: return super().label_from_instance(obj) else: return loader.render_to_string( - self.template_name, context={self.context_object_name: obj} + self.label_template_name, context={self.context_object_name: obj} ) @@ -135,7 +146,8 @@ class InscriptionReventeForm(forms.Form): queryset=Spectacle.objects.none(), widget=forms.CheckboxSelectMultiple, required=False, - template_name="bda/forms/spectacle_label_table.html", + label_template_name="bda/forms/spectacle_label_table.html", + option_template_name="bda/forms/checkbox_table.html", context_object_name="spectacle", ) @@ -144,9 +156,6 @@ class InscriptionReventeForm(forms.Form): self.fields["spectacles"].queryset = tirage.spectacle_set.select_related( "location" ).filter(date__gte=timezone.now()) - self.fields[ - "spectacles" - ].widget.option_template_name = "bda/forms/spectacle_checkbox_table.html" class ReventeTirageAnnulForm(forms.Form): diff --git a/bda/templates/bda/forms/spectacle_checkbox_table.html b/bda/templates/bda/forms/checkbox_table.html similarity index 100% rename from bda/templates/bda/forms/spectacle_checkbox_table.html rename to bda/templates/bda/forms/checkbox_table.html From 5c8164dd3b4ac1ec9468c0d6c746e7dd463ac310 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Fri, 7 Dec 2018 17:35:40 +0100 Subject: [PATCH 03/15] Prettify revente/tirages --- bda/forms.py | 35 +++++----- .../bda/forms/revente_other_label_table.html | 8 +++ bda/templates/bda/revente/tirages.html | 70 ++++++++++++++----- bda/views.py | 5 +- 4 files changed, 81 insertions(+), 37 deletions(-) create mode 100644 bda/templates/bda/forms/revente_other_label_table.html diff --git a/bda/forms.py b/bda/forms.py index 8230056d..54c62932 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -159,12 +159,13 @@ class InscriptionReventeForm(forms.Form): class ReventeTirageAnnulForm(forms.Form): - reventes = ReventeModelMultipleChoiceField( - own=False, - label="", + reventes = TemplateLabelField( queryset=SpectacleRevente.objects.none(), 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", ) def __init__(self, participant, *args, **kwargs): @@ -175,22 +176,22 @@ class ReventeTirageAnnulForm(forms.Form): class ReventeTirageForm(forms.Form): - reventes = ReventeModelMultipleChoiceField( - own=False, - label="", - queryset=SpectacleRevente.objects.none(), - widget=forms.CheckboxSelectMultiple, - required=False, - ) - def __init__(self, participant, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["reventes"].queryset = ( - SpectacleRevente.objects.filter( - notif_sent=True, shotgun=False, tirage_done=False - ) - .exclude(confirmed_entry=participant) - .select_related("attribution__spectacle") + + self.fields["reventes"] = TemplateLabelField( + queryset=( + SpectacleRevente.objects.filter( + notif_sent=True, shotgun=False, tirage_done=False + ) + .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", ) diff --git a/bda/templates/bda/forms/revente_other_label_table.html b/bda/templates/bda/forms/revente_other_label_table.html new file mode 100644 index 00000000..c2047ff3 --- /dev/null +++ b/bda/templates/bda/forms/revente_other_label_table.html @@ -0,0 +1,8 @@ +{% with spectacle=revente.attribution.spectacle user=revente.seller.user %} +{{ spectacle.title }} +{{ spectacle.date }} +{{ spectacle.location }} +{{ spectacle.price |floatformat }}€ +{{user.first_name}} {{user.last_name}} +{{ revente.date_tirage }} +{% endwith %} \ No newline at end of file diff --git a/bda/templates/bda/revente/tirages.html b/bda/templates/bda/revente/tirages.html index b7017806..881e1e0a 100644 --- a/bda/templates/bda/revente/tirages.html +++ b/bda/templates/bda/revente/tirages.html @@ -1,45 +1,69 @@ {% extends "base_title.html" %} -{% load bootstrap %} +{% load staticfiles %} {% block realcontent %}

    Tirages au sort de reventes

    -{% if annulform.reventes %} +{% if annul_exists %}

    Les reventes auxquelles vous êtes inscrit·e

    -
    - - Vous pouvez vous désinscrire des reventes suivantes tant que le tirage n'a - pas eu lieu. -
    -
    - {% csrf_token %} - {{ annulform|bootstrap }} -
    +

    + 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. +

    + {% csrf_token %} + + + + + + + + + + + + + + {% for checkbox in annulform.reventes %}{{ checkbox }}{% endfor %} + +
    TitreDateLieuPrixVendue parTirage le
    + value="Se désister des tirages sélectionnés">
    - -
    {% endif %} -{% if subform.reventes %} +
    + +{% if sub_exists %}

    Tirages en cours

    - Vous pouvez vous inscrire aux tirage en cours suivants. + Vous pouvez vous inscrire aux tirages en cours suivants.
    -
    {% csrf_token %} - {{ subform|bootstrap }} -
    + + + + + + + + + + + + + + {% for checkbox in subform.reventes %}{{ checkbox }}{% endfor %} + +
    TitreDateLieuPrixVendue parTirage le
    {% endif %} +{% if not annul_exists and not sub_exists %} +
    + + Aucune revente n'est active pour le moment ! +
    +{% endif %} + + {% endblock %} diff --git a/bda/views.py b/bda/views.py index 7e8d9167..9d550eab 100644 --- a/bda/views.py +++ b/bda/views.py @@ -500,10 +500,13 @@ def revente_tirages(request, tirage_id): ), ) + 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}, + {"annulform": annulform, "subform": subform, "annul_exists": annul_exists, "sub_exists": sub_exists}, ) From 6be42d57ca1639a6231d5eefb2ce60bb30e879e1 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Fri, 7 Dec 2018 17:35:53 +0100 Subject: [PATCH 04/15] Prettify revente/shotgun --- bda/templates/bda/revente/shotgun.html | 25 ++++++++++++++++++++----- bda/views.py | 2 +- gestioncof/static/css/cof.css | 12 ++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/bda/templates/bda/revente/shotgun.html b/bda/templates/bda/revente/shotgun.html index fae36c04..a724032e 100644 --- a/bda/templates/bda/revente/shotgun.html +++ b/bda/templates/bda/revente/shotgun.html @@ -2,11 +2,26 @@ {% block realcontent %}

    Places disponibles immédiatement

    - {% if shotgun %} -
      - {% for spectacle in shotgun %} -
    • {{spectacle}}
    • - {% endfor %} + {% if spectacles %} + + + + + + + + + + + + {% for spectacle in spectacles %} + + {% include "bda/forms/spectacle_label_table.html" with spectacle=spectacle %} + + {% endfor %} + +
      TitreDateLieuPrix
      Racheter +
      {% else %}

      Pas de places disponibles immédiatement, désolé !

      {% endif %} diff --git a/bda/views.py b/bda/views.py index 9d550eab..8d5b7ab0 100644 --- a/bda/views.py +++ b/bda/views.py @@ -660,7 +660,7 @@ def revente_shotgun(request, tirage_id): ) shotgun = [sp for sp in spectacles if len(sp.shotguns) > 0] - return render(request, "bda/revente/shotgun.html", {"shotgun": shotgun}) + return render(request, "bda/revente/shotgun.html", {"spectacles": shotgun}) @buro_required diff --git a/gestioncof/static/css/cof.css b/gestioncof/static/css/cof.css index 84c81d60..51a7ab65 100644 --- a/gestioncof/static/css/cof.css +++ b/gestioncof/static/css/cof.css @@ -187,6 +187,18 @@ form.petit-cours_form .remove-btn { margin-top : 10px; } +table#bda-shotgun td { + padding: 14px; +} + +table#bda-shotgun td.button { + padding: 8px; +} + +table#bda-shotgun a, table#bda-shotgun a:hover { + color: #FFF +} + tr.dynamic-form td { background: #F0F0F0; } From 66104e11370b74daa1a28c89d663063fdb68d134 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Sat, 8 Dec 2018 10:41:46 +0100 Subject: [PATCH 05/15] Black --- bda/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bda/views.py b/bda/views.py index c750bf51..f5be2e74 100644 --- a/bda/views.py +++ b/bda/views.py @@ -511,7 +511,12 @@ def revente_tirages(request, tirage_id): return render( request, "bda/revente/tirages.html", - {"annulform": annulform, "subform": subform, "annul_exists": annul_exists, "sub_exists": sub_exists}, + { + "annulform": annulform, + "subform": subform, + "annul_exists": annul_exists, + "sub_exists": sub_exists, + }, ) From a30955fb7530abd38fb03911b48413e5c14f9b3e Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 7 Jan 2019 14:43:32 +0100 Subject: [PATCH 06/15] HTML corrections ; stupidtable injection --- bda/templates/bda/revente/manage.html | 17 +++++++++++------ bda/templates/bda/revente/tirages.html | 14 +++++++++++--- bda/views.py | 7 +++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/bda/templates/bda/revente/manage.html b/bda/templates/bda/revente/manage.html index 5147ff16..4f1d014e 100644 --- a/bda/templates/bda/revente/manage.html +++ b/bda/templates/bda/revente/manage.html @@ -4,9 +4,8 @@ {% block realcontent %}

      Gestion des places que je revends

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

      Places non revendues

      @@ -29,7 +28,7 @@
      {% endif %} -{% if annul_reventes %} +{% if annul_exists %}

      Places en cours de revente

      @@ -52,7 +51,7 @@
      {% endif %} -{% if sold_reventes %} +{% if sold_exists %}

      Places revendues

      @@ -72,9 +71,15 @@ {% endif %} -{% if not resell_attributions and not annul_reventes and not sold_reventes %} +{% if not resell_exists and not annul_exists and not sold_exists %}

      Plus de reventes possibles !

      {% endif %} -{% endwith %} + + + {% endblock %} diff --git a/bda/templates/bda/revente/tirages.html b/bda/templates/bda/revente/tirages.html index 881e1e0a..c14093be 100644 --- a/bda/templates/bda/revente/tirages.html +++ b/bda/templates/bda/revente/tirages.html @@ -8,9 +8,10 @@ {% if annul_exists %}

      Les reventes auxquelles vous êtes inscrit·e

      -

      - 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. -

      +
      + + 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. +
      {% csrf_token %} @@ -80,5 +81,12 @@ {% endif %} + + + {% endblock %} diff --git a/bda/views.py b/bda/views.py index f5be2e74..7a1774b3 100644 --- a/bda/views.py +++ b/bda/views.py @@ -450,6 +450,10 @@ def revente_manage(request, tirage_id): 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", @@ -458,6 +462,9 @@ def revente_manage(request, tirage_id): "soldform": soldform, "annulform": annulform, "resellform": resellform, + "sold_exists": sold_exists, + "annul_exists": annul_exists, + "resell_exists": resell_exists, }, ) From 519ef9dc20a74ac398ff4cbb39764221716106ac Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 7 Jan 2019 14:59:20 +0100 Subject: [PATCH 07/15] Fix concurrency issues Creating form fields in the class and modifiying them dynamically can cause concurrency issues because the form class is shared between tabs. --- bda/forms.py | 83 ++++++++++++++++++++++++---------------------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/bda/forms.py b/bda/forms.py index 54c62932..02939de4 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -90,24 +90,20 @@ class ResellForm(forms.Form): class AnnulForm(forms.Form): - reventes = ReventeModelMultipleChoiceField( - own=True, - label="", - queryset=Attribution.objects.none(), - widget=forms.CheckboxSelectMultiple, - required=False, - ) - def __init__(self, participant, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["reventes"].queryset = ( - participant.original_shows.filter( + self.fields["reventes"] = ReventeModelMultipleChoiceField( + own=True, + 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") + .order_by("-date"), + widget=forms.CheckboxSelectMultiple, + required=False, ) @@ -142,37 +138,37 @@ class TemplateLabelField(forms.ModelMultipleChoiceField): class InscriptionReventeForm(forms.Form): - spectacles = TemplateLabelField( - queryset=Spectacle.objects.none(), - 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", - ) - def __init__(self, tirage, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["spectacles"].queryset = tirage.spectacle_set.select_related( - "location" - ).filter(date__gte=timezone.now()) + 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", + ) class ReventeTirageAnnulForm(forms.Form): - reventes = TemplateLabelField( - queryset=SpectacleRevente.objects.none(), - 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", - ) - def __init__(self, participant, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["reventes"].queryset = participant.entered.filter( - soldTo__isnull=True - ).select_related("attribution__spectacle", "seller__user") + 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", + ) + + participant.entered.filter(soldTo__isnull=True).select_related( + "attribution__spectacle", "seller__user" + ) class ReventeTirageForm(forms.Form): @@ -196,19 +192,16 @@ class ReventeTirageForm(forms.Form): class SoldForm(forms.Form): - reventes = ReventeModelMultipleChoiceField( - own=True, - label="", - queryset=Attribution.objects.none(), - widget=forms.CheckboxSelectMultiple, - ) - def __init__(self, participant, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["reventes"].queryset = ( - participant.original_shows.filter(soldTo__isnull=False) + self.fields["reventes"].queryset = ReventeModelMultipleChoiceField( + own=True, + label="", + queryset=participant.original_shows.filter(soldTo__isnull=False) .exclude(soldTo=participant) .select_related( "attribution__spectacle", "attribution__spectacle__location" - ) + ), + widget=forms.CheckboxSelectMultiple, ) + From 010ce0df3e1d64c6a6efdf30959a7b9c42c72594 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 7 Jan 2019 15:27:41 +0100 Subject: [PATCH 08/15] Bugfixes : add staticfiles and typo --- bda/forms.py | 62 ++++++++++++------------ bda/templates/bda/revente/manage.html | 1 + bda/templates/bda/revente/subscribe.html | 1 - bda/templates/bda/revente/tirages.html | 1 - 4 files changed, 32 insertions(+), 33 deletions(-) diff --git a/bda/forms.py b/bda/forms.py index 02939de4..f3b41257 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -39,6 +39,36 @@ 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} + ) + + class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, obj): return str(obj.spectacle) @@ -107,36 +137,6 @@ class AnnulForm(forms.Form): ) -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} - ) - - class InscriptionReventeForm(forms.Form): def __init__(self, tirage, *args, **kwargs): super().__init__(*args, **kwargs) @@ -194,7 +194,7 @@ class ReventeTirageForm(forms.Form): class SoldForm(forms.Form): def __init__(self, participant, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["reventes"].queryset = ReventeModelMultipleChoiceField( + self.fields["reventes"] = ReventeModelMultipleChoiceField( own=True, label="", queryset=participant.original_shows.filter(soldTo__isnull=False) diff --git a/bda/templates/bda/revente/manage.html b/bda/templates/bda/revente/manage.html index 4f1d014e..ef468403 100644 --- a/bda/templates/bda/revente/manage.html +++ b/bda/templates/bda/revente/manage.html @@ -1,5 +1,6 @@ {% extends "base_title.html" %} {% load bootstrap %} +{% load staticfiles %} {% block realcontent %} diff --git a/bda/templates/bda/revente/subscribe.html b/bda/templates/bda/revente/subscribe.html index 8a9545eb..b26bc79e 100644 --- a/bda/templates/bda/revente/subscribe.html +++ b/bda/templates/bda/revente/subscribe.html @@ -42,7 +42,6 @@ value="S'inscrire pour les places sélectionnées"> - {% endblock %} diff --git a/bda/templates/bda/revente/subscribe.html b/bda/templates/bda/revente/subscribe.html index b26bc79e..5db11d78 100644 --- a/bda/templates/bda/revente/subscribe.html +++ b/bda/templates/bda/revente/subscribe.html @@ -42,6 +42,7 @@ value="S'inscrire pour les places sélectionnées"> + {% endblock %} diff --git a/bda/templates/bda/revente/tirages.html b/bda/templates/bda/revente/tirages.html index d3ec7b1f..1d72e9b5 100644 --- a/bda/templates/bda/revente/tirages.html +++ b/bda/templates/bda/revente/tirages.html @@ -81,10 +81,14 @@ {% endif %} + From 47c02d72afd5571809c10bfb2faffce813116a01 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 7 Jan 2019 16:43:46 +0100 Subject: [PATCH 11/15] =?UTF-8?q?R=C3=A9organisation=20de=20`bda/forms`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppression de code mort, tri des formulaires Remove bootstrap forms loading --- bda/forms.py | 78 ++++++++++----------------- bda/templates/bda/revente/manage.html | 1 - 2 files changed, 28 insertions(+), 51 deletions(-) diff --git a/bda/forms.py b/bda/forms.py index ad163ceb..b370f3ae 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -69,37 +69,7 @@ class TemplateLabelField(forms.ModelMultipleChoiceField): ) -class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField): - def label_from_instance(self, obj): - return str(obj.spectacle) - - -class ReventeModelMultipleChoiceField(forms.ModelMultipleChoiceField): - def __init__(self, *args, own=True, **kwargs): - super().__init__(*args, **kwargs) - self.own = own - - def label_from_instance(self, obj): - label = "{show}{suffix}" - suffix = "" - if self.own: - # C'est notre propre revente : informations sur le statut - if obj.soldTo is not None: - suffix = " -- Vendue à {firstname} {lastname}".format( - firstname=obj.soldTo.user.first_name, - lastname=obj.soldTo.user.last_name, - ) - elif obj.shotgun: - suffix = " -- Tirage infructueux" - elif obj.notif_sent: - suffix = " -- Inscriptions au tirage en cours" - else: - # Ce n'est pas à nous : on ne voit jamais l'acheteur - suffix = " -- Vendue par {firstname} {lastname}".format( - firstname=obj.seller.user.first_name, lastname=obj.seller.user.last_name - ) - - return label.format(show=str(obj.attribution.spectacle), suffix=suffix) +# Formulaires pour revente_manage class ResellForm(forms.Form): @@ -139,6 +109,26 @@ class AnnulForm(forms.Form): ) +class SoldForm(forms.Form): + def __init__(self, participant, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["reventes"] = TemplateLabelField( + label="", + 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) @@ -154,6 +144,9 @@ class InscriptionReventeForm(forms.Form): ) +# Formulaires pour revente_tirages + + class ReventeTirageAnnulForm(forms.Form): def __init__(self, participant, *args, **kwargs): super().__init__(*args, **kwargs) @@ -180,7 +173,10 @@ class ReventeTirageForm(forms.Form): self.fields["reventes"] = TemplateLabelField( queryset=( SpectacleRevente.objects.filter( - notif_sent=True, shotgun=False, tirage_done=False + notif_sent=True, + shotgun=False, + tirage_done=False, + attribution__spectacle__tirage=participant.tirage, ) .exclude(confirmed_entry=participant) .select_related("attribution__spectacle") @@ -191,21 +187,3 @@ class ReventeTirageForm(forms.Form): 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( - label="", - 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", - ) - diff --git a/bda/templates/bda/revente/manage.html b/bda/templates/bda/revente/manage.html index a092664b..4e69aef7 100644 --- a/bda/templates/bda/revente/manage.html +++ b/bda/templates/bda/revente/manage.html @@ -1,5 +1,4 @@ {% extends "base_title.html" %} -{% load bootstrap %} {% load staticfiles %} {% block realcontent %} From 445745ee156ceb994c1b9ade29e94b58693307cd Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 7 Jan 2019 22:34:28 +0100 Subject: [PATCH 12/15] Merge branch 'master' into Aufinal/prettify-revente Merge remote-tracking branch 'origin/master' into Aufinal/prettify-revente --- .gitlab-ci.yml | 8 +- bda/tests/test_views.py | 481 +++++++---- bda/tests/testcases.py | 75 ++ bda/tests/utils.py | 36 + bda/urls.py | 2 +- cof/settings/common.py | 1 + cof/settings/dev.py | 2 +- cof/urls.py | 3 +- gestioncof/admin.py | 2 +- gestioncof/decorators.py | 62 +- gestioncof/management/commands/loaddevdata.py | 2 +- gestioncof/models.py | 2 +- gestioncof/signals.py | 2 +- gestioncof/templates/buro-denied.html | 5 + gestioncof/tests/utils.py | 39 - gestioncof/urls.py | 37 +- kfet/open/tests.py | 35 +- kfet/tests/test_tests_utils.py | 91 +- kfet/tests/test_views.py | 812 +++++++++++++++++- kfet/tests/utils.py | 193 ++++- kfet/views.py | 30 +- petitscours/__init__.py | 0 .../forms.py | 2 +- .../models.py | 49 ++ .../templates/petitscours/base_title.html | 0 .../templates/petitscours/demande.html | 0 .../templates/petitscours/demande_detail.html | 4 +- .../templates/petitscours/demande_list.html | 2 +- .../templates/petitscours/demande_raw.html | 0 .../petitscours/details_demande_infos.html | 0 .../templates/petitscours/inscription.html | 2 +- .../petitscours/inscription_formset.html | 0 .../petitscours/traitement_demande.html | 4 +- .../traitement_demande_autre_niveau.html | 4 +- .../traitement_demande_success.html | 2 +- petitscours/tests/__init__.py | 0 .../tests/test_petitscours_views.py | 0 petitscours/tests/utils.py | 39 + petitscours/urls.py | 37 + .../views.py | 170 +--- requirements.txt | 2 +- setup.cfg | 3 +- shared/tests/testcases.py | 1 + 43 files changed, 1788 insertions(+), 453 deletions(-) create mode 100644 bda/tests/testcases.py create mode 100644 bda/tests/utils.py create mode 100644 gestioncof/templates/buro-denied.html create mode 100644 petitscours/__init__.py rename gestioncof/petits_cours_forms.py => petitscours/forms.py (95%) rename gestioncof/petits_cours_models.py => petitscours/models.py (77%) rename gestioncof/templates/base_title_petitscours.html => petitscours/templates/petitscours/base_title.html (100%) rename gestioncof/templates/demande-petit-cours.html => petitscours/templates/petitscours/demande.html (100%) rename gestioncof/templates/gestioncof/details_demande_petit_cours.html => petitscours/templates/petitscours/demande_detail.html (93%) rename gestioncof/templates/petits_cours_demandes_list.html => petitscours/templates/petitscours/demande_list.html (97%) rename gestioncof/templates/demande-petit-cours-raw.html => petitscours/templates/petitscours/demande_raw.html (100%) rename gestioncof/templates/details_demande_petit_cours_infos.html => petitscours/templates/petitscours/details_demande_infos.html (100%) rename gestioncof/templates/inscription-petit-cours.html => petitscours/templates/petitscours/inscription.html (98%) rename gestioncof/templates/inscription-petit-cours-formset.html => petitscours/templates/petitscours/inscription_formset.html (100%) rename gestioncof/templates/gestioncof/traitement_demande_petit_cours.html => petitscours/templates/petitscours/traitement_demande.html (94%) rename gestioncof/templates/gestioncof/traitement_demande_petit_cours_autre_niveau.html => petitscours/templates/petitscours/traitement_demande_autre_niveau.html (95%) rename gestioncof/templates/gestioncof/traitement_demande_petit_cours_success.html => petitscours/templates/petitscours/traitement_demande_success.html (89%) create mode 100644 petitscours/tests/__init__.py rename {gestioncof => petitscours}/tests/test_petitscours_views.py (100%) create mode 100644 petitscours/tests/utils.py create mode 100644 petitscours/urls.py rename gestioncof/petits_cours_views.py => petitscours/views.py (65%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 35637457..c001fc7c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -27,10 +27,10 @@ test: - sed -i.bak -E 's;^REDIS_PASSWD = .*$;REDIS_PASSWD = "";' cof/settings/secret.py # Remove the old test database if it has not been done yet - psql --username=$POSTGRES_USER --host=$DBHOST -c "DROP DATABASE IF EXISTS test_$POSTGRES_DB" - - pip install --upgrade -r requirements.txt coverage + - pip install --upgrade -r requirements.txt coverage tblib - python --version script: - - coverage run manage.py test + - coverage run manage.py test --parallel after_script: - coverage report services: @@ -52,9 +52,9 @@ linters: - pip install --upgrade black isort flake8 script: - black --check . - - isort --recursive --check-only --diff bda cof gestioncof kfet provisioning shared utils + - isort --recursive --check-only --diff bda cof gestioncof kfet petitscours provisioning shared utils # Print errors only - - flake8 --exit-zero bda cof gestioncof kfet provisioning shared utils + - flake8 --exit-zero bda cof gestioncof kfet petitscours provisioning shared utils cache: key: linters paths: diff --git a/bda/tests/test_views.py b/bda/tests/test_views.py index 6bfa3257..1d4494cb 100644 --- a/bda/tests/test_views.py +++ b/bda/tests/test_views.py @@ -1,225 +1,357 @@ import json -import os from datetime import timedelta -from unittest import mock -from urllib.parse import urlencode -from django.conf import settings -from django.contrib.auth.models import User -from django.core.management import call_command -from django.test import Client, TestCase -from django.utils import timezone +from django.test import TestCase +from django.utils import formats, timezone -from ..models import CategorieSpectacle, Salle, Spectacle, Tirage +from ..models import CategorieSpectacle, Participant, Salle +from .testcases import BdATestHelpers, BdAViewTestCaseMixin +from .utils import create_spectacle -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 +class InscriptionViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase): + url_name = "bda-tirage-inscription" + http_methods = ["GET", "POST"] -def user_is_cof(user): - return (user is not None) and user.profile.is_cof + auth_user = "bda_member" + auth_forbidden = [None, "bda_other"] + bda_testdata = True -def user_is_staff(user): - return (user is not None) and user.profile.is_buro + @property + def url_kwargs(self): + return {"tirage_id": self.tirage.id} + @property + def url_expected(self): + return "/bda/inscription/{}".format(self.tirage.id) -class BdATestHelpers: - def setUp(self): - # Some user with different access privileges - staff = create_user(username="bda_staff", is_cof=True, is_buro=True) - staff_c = Client() - staff_c.force_login(staff) + def test_get_opened(self): + self.tirage.ouverture = timezone.now() - timedelta(days=1) + self.tirage.fermeture = timezone.now() + timedelta(days=1) + self.tirage.save() - member = create_user(username="bda_member", is_cof=True) - member_c = Client() - member_c.force_login(member) + resp = self.client.get(self.url) - other = create_user(username="bda_other") - other_c = Client() - other_c.force_login(other) + self.assertEqual(resp.status_code, 200) + self.assertFalse(resp.context["messages"]) - self.client_matrix = [ - (staff, staff_c), - (member, member_c), - (other, other_c), - (None, Client()), - ] + 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() - def require_custommails(self): - data_file = os.path.join( - settings.BASE_DIR, "gestioncof", "management", "data", "custommail.json" - ) - call_command("syncmails", data_file, verbosity=0) + resp = self.client.get(self.url) - def check_restricted_access( - self, url, validate_user=user_is_cof, redirect_url=None - ): - def craft_redirect_url(user): - if redirect_url: - return redirect_url - elif user is None: - # client is not logged in - login_url = "/login" - if url: - login_url += "?{}".format(urlencode({"next": url}, safe="/")) - return login_url - else: - return "/" - - for (user, client) in self.client_matrix: - resp = client.get(url, follow=True) - if validate_user(user): - self.assertEqual(200, resp.status_code) - else: - self.assertRedirects(resp, craft_redirect_url(user)) - - -class TestBdAViews(BdATestHelpers, TestCase): - def setUp(self): - # 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) - # Set up the helpers - super().setUp() - # Some BdA stuff - 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") - Spectacle.objects.bulk_create( - [ - Spectacle( - title="foo", - date=timezone.now(), - location=self.location, - price=0, - slots=42, - tirage=self.tirage, - listing=False, - category=self.category, - ), - Spectacle( - title="bar", - date=timezone.now(), - location=self.location, - price=1, - slots=142, - tirage=self.tirage, - listing=False, - category=self.category, - ), - Spectacle( - title="baz", - date=timezone.now(), - location=self.location, - price=2, - slots=242, - tirage=self.tirage, - listing=False, - category=self.category, - ), - ] + 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_bda_inscriptions(self): - # TODO: test the form - url = "/bda/inscription/{}".format(self.tirage.id) - self.check_restricted_access(url) + 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() - def test_bda_places(self): - url = "/bda/places/{}".format(self.tirage.id) - self.check_restricted_access(url) + resp = self.client.get(self.url) - def test_etat_places(self): - url = "/bda/etat-places/{}".format(self.tirage.id) - self.check_restricted_access(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 test_perform_tirage(self): - # Only staff member can perform a tirage - url = "/bda/tirage/{}".format(self.tirage.id) - self.check_restricted_access(url, validate_user=user_is_staff) + 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", + } - _, staff_c = self.client_matrix[0] + 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 "/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 "/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 "/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 = staff_c.get(url) + 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 = staff_c.get(url) + 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 = staff_c.get(url) + resp = self.client.get(self.url) self.assertTemplateNotUsed(resp, "tirage-failed.html") - def test_spectacles_list(self): - url = "/bda/spectacles/{}".format(self.tirage.id) - self.check_restricted_access(url, validate_user=user_is_staff) - def test_spectacle_detail(self): - show = self.tirage.spectacle_set.first() - url = "/bda/spectacles/{}/{}".format(self.tirage.id, show.id) - self.check_restricted_access(url, validate_user=user_is_staff) +class SpectacleListViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase): + url_name = "bda-liste-spectacles" - def test_tirage_unpaid(self): - url = "/bda/spectacles/unpaid/{}".format(self.tirage.id) - self.check_restricted_access(url, validate_user=user_is_staff) + auth_user = "bda_staff" + auth_forbidden = [None, "bda_other", "bda_member"] - def test_send_reminders(self): + bda_testdata = True + + @property + def url_kwargs(self): + return {"tirage_id": self.tirage.id} + + @property + def url_expected(self): + return "/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 "/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 "/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 "/bda/mails-rappel/{}".format(self.show1.id) + + def test_post(self): self.require_custommails() - # Just get the page - show = self.tirage.spectacle_set.first() - url = "/bda/mails-rappel/{}".format(show.id) - self.check_restricted_access(url, validate_user=user_is_staff) - # Actually send the reminder emails - _, staff_c = self.client_matrix[0] - resp = staff_c.post(url) + resp = self.client.post(self.url) self.assertEqual(200, resp.status_code) # TODO: check that emails are sent - def test_catalogue_api(self): + +class DescriptionsSpectaclesViewTestCase( + BdATestHelpers, BdAViewTestCaseMixin, TestCase +): + url_name = "bda-descriptions" + + auth_user = None + auth_forbidden = [] + + bda_testdata = True + + @property + def url_kwargs(self): + return {"tirage_id": self.tirage.pk} + + @property + def url_expected(self): + return "/bda/descriptions/{}".format(self.tirage.pk) + + def test_get(self): + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 200) + self.assertListEqual( + list(resp.context["shows"]), [self.show1, self.show2, self.show3] + ) + + def test_get_filter_category(self): + category1 = CategorieSpectacle.objects.create(name="Category 1") + category2 = CategorieSpectacle.objects.create(name="Category 2") + show1 = create_spectacle(category=category1, tirage=self.tirage) + show2 = create_spectacle(category=category2, tirage=self.tirage) + + resp = self.client.get(self.url, {"category": "Category 1"}) + self.assertEqual(resp.status_code, 200) + self.assertListEqual(list(resp.context["shows"]), [show1]) + + resp = self.client.get(self.url, {"category": "Category 2"}) + self.assertEqual(resp.status_code, 200) + self.assertListEqual(list(resp.context["shows"]), [show2]) + + def test_get_filter_location(self): + location1 = Salle.objects.create(name="Location 1") + location2 = Salle.objects.create(name="Location 2") + show1 = create_spectacle(location=location1, tirage=self.tirage) + show2 = create_spectacle(location=location2, tirage=self.tirage) + + resp = self.client.get(self.url, {"location": str(location1.pk)}) + self.assertEqual(resp.status_code, 200) + self.assertListEqual(list(resp.context["shows"]), [show1]) + + resp = self.client.get(self.url, {"location": str(location2.pk)}) + self.assertEqual(resp.status_code, 200) + self.assertListEqual(list(resp.context["shows"]), [show2]) + + +class CatalogueViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase): + auth_user = None + auth_forbidden = [] + + bda_testdata = True + + def test_api_list(self): url_list = "/bda/catalogue/list" - url_details = "/bda/catalogue/details?id={}".format(self.tirage.id) - url_descriptions = "/bda/catalogue/descriptions?id={}".format(self.tirage.id) - - # Anyone can get - def anyone_can_get(url): - self.check_restricted_access(url, validate_user=lambda user: True) - - anyone_can_get(url_list) - anyone_can_get(url_details) - anyone_can_get(url_descriptions) - - # The resulting JSON contains the information - _, client = self.client_matrix[0] - - # List - resp = client.get(url_list) + resp = self.client.get(url_list) self.assertJSONEqual( resp.content.decode("utf-8"), [{"id": self.tirage.id, "title": self.tirage.title}], ) - # Details - resp = client.get(url_details) + def test_api_details(self): + url_details = "/bda/catalogue/details?id={}".format(self.tirage.id) + resp = self.client.get(url_details) self.assertJSONEqual( resp.content.decode("utf-8"), { @@ -228,8 +360,9 @@ class TestBdAViews(BdATestHelpers, TestCase): }, ) - # Descriptions - resp = client.get(url_descriptions) + def test_api_descriptions(self): + url_descriptions = "/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) diff --git a/bda/tests/testcases.py b/bda/tests/testcases.py new file mode 100644 index 00000000..f5ac7f83 --- /dev/null +++ b/bda/tests/testcases.py @@ -0,0 +1,75 @@ +import os + +from django.conf import settings +from django.core.management import call_command +from django.utils import timezone + +from shared.tests.testcases 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 require_custommails(self): + data_file = os.path.join( + settings.BASE_DIR, "gestioncof", "management", "data", "custommail.json" + ) + call_command("syncmails", data_file, verbosity=0) + + 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, + ) diff --git a/bda/tests/utils.py b/bda/tests/utils.py new file mode 100644 index 00000000..68f51fb6 --- /dev/null +++ b/bda/tests/utils.py @@ -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)) diff --git a/bda/urls.py b/bda/urls.py index 7ceccfe0..7ac21648 100644 --- a/bda/urls.py +++ b/bda/urls.py @@ -12,7 +12,7 @@ urlpatterns = [ ), url(r"^places/(?P\d+)$", views.places, name="bda-places-attribuees"), url(r"^etat-places/(?P\d+)$", views.etat_places, name="bda-etat-places"), - url(r"^tirage/(?P\d+)$", views.tirage), + url(r"^tirage/(?P\d+)$", views.tirage, name="bda-tirage"), url( r"^spectacles/(?P\d+)$", buro_required(SpectacleListView.as_view()), diff --git a/cof/settings/common.py b/cof/settings/common.py index 4c853a16..50622f72 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -68,6 +68,7 @@ INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.admindocs", "bda", + "petitscours", "captcha", "django_cas_ng", "bootstrapform", diff --git a/cof/settings/dev.py b/cof/settings/dev.py index 0fd367cd..fc2f13f9 100644 --- a/cof/settings/dev.py +++ b/cof/settings/dev.py @@ -37,7 +37,7 @@ def show_toolbar(request): machine physique n'est pas forcément connue, et peut difficilement être mise dans les INTERNAL_IPS. """ - return DEBUG + return DEBUG and not request.path.startswith("/admin/") if not TESTING: diff --git a/cof/urls.py b/cof/urls.py index 7a0bee4c..c952cd98 100644 --- a/cof/urls.py +++ b/cof/urls.py @@ -20,7 +20,6 @@ from gestioncof.urls import ( clubs_patterns, events_patterns, export_patterns, - petitcours_patterns, surveys_patterns, ) @@ -34,7 +33,7 @@ urlpatterns = [ # Les exports url(r"^export/", include(export_patterns)), # Les petits cours - url(r"^petitcours/", include(petitcours_patterns)), + url(r"^petitcours/", include("petitscours.urls")), # Les sondages url(r"^survey/", include(surveys_patterns)), # Evenements diff --git a/gestioncof/admin.py b/gestioncof/admin.py index e89d4271..f0fd2a43 100644 --- a/gestioncof/admin.py +++ b/gestioncof/admin.py @@ -20,7 +20,7 @@ from gestioncof.models import ( SurveyQuestion, SurveyQuestionAnswer, ) -from gestioncof.petits_cours_models import ( +from petitscours.models import ( PetitCoursAbility, PetitCoursAttribution, PetitCoursAttributionCounter, diff --git a/gestioncof/decorators.py b/gestioncof/decorators.py index ef811730..37d93c7f 100644 --- a/gestioncof/decorators.py +++ b/gestioncof/decorators.py @@ -1,23 +1,55 @@ -from django.contrib.auth.decorators import user_passes_test +from functools import wraps + +from django.contrib.auth.decorators import login_required, user_passes_test +from django.core.exceptions import PermissionDenied +from django.shortcuts import render -def is_cof(user): - try: - profile = user.profile - return profile.is_cof - except Exception: - return False +def cof_required(view_func): + """Décorateur qui vérifie que l'utilisateur est connecté et membre du COF. + + - Si l'utilisteur n'est pas connecté, il est redirigé vers la page de + connexion + - Si l'utilisateur est connecté mais pas membre du COF, il obtient une + page d'erreur lui demandant de s'inscrire au COF + """ + + def is_cof(user): + try: + return user.profile.is_cof + except AttributeError: + return False + + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + if is_cof(request.user): + return view_func(request, *args, **kwargs) + + return render(request, "cof-denied.html", status=403) + + return login_required(_wrapped_view) -cof_required = user_passes_test(is_cof) +def buro_required(view_func): + """Décorateur qui vérifie que l'utilisateur est connecté et membre du burô. + - Si l'utilisateur n'est pas connecté, il est redirigé vers la page de + connexion + - Si l'utilisateur est connecté mais pas membre du burô, il obtient une + page d'erreur 403 Forbidden + """ -def is_buro(user): - try: - profile = user.profile - return profile.is_buro - except Exception: - return False + def is_buro(user): + try: + return user.profile.is_buro + except AttributeError: + return False + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + if is_buro(request.user): + return view_func(request, *args, **kwargs) -buro_required = user_passes_test(is_buro) + return render(request, "buro-denied.html", status=403) + + return login_required(_wrapped_view) diff --git a/gestioncof/management/commands/loaddevdata.py b/gestioncof/management/commands/loaddevdata.py index 44d77065..05336050 100644 --- a/gestioncof/management/commands/loaddevdata.py +++ b/gestioncof/management/commands/loaddevdata.py @@ -14,7 +14,7 @@ from django.contrib.auth.models import User from django.core.management import call_command from gestioncof.management.base import MyBaseCommand -from gestioncof.petits_cours_models import ( +from petitscours.models import ( LEVELS_CHOICES, PetitCoursAbility, PetitCoursAttributionCounter, diff --git a/gestioncof/models.py b/gestioncof/models.py index 227fa936..98b947a1 100644 --- a/gestioncof/models.py +++ b/gestioncof/models.py @@ -5,7 +5,7 @@ from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ from bda.models import Spectacle -from gestioncof.petits_cours_models import choices_length +from petitscours.models import choices_length TYPE_COMMENT_FIELD = (("text", _("Texte long")), ("char", _("Texte court"))) diff --git a/gestioncof/signals.py b/gestioncof/signals.py index 3614b1c8..cf4b1f16 100644 --- a/gestioncof/signals.py +++ b/gestioncof/signals.py @@ -15,7 +15,7 @@ def messages_on_out_login(request, user, **kwargs): @receiver(cas_user_authenticated) -def mesagges_on_cas_login(request, user, **kwargs): +def messages_on_cas_login(request, user, **kwargs): msg = _("Connexion à GestioCOF par CAS réussie. Bienvenue {}.").format( user.get_short_name() ) diff --git a/gestioncof/templates/buro-denied.html b/gestioncof/templates/buro-denied.html new file mode 100644 index 00000000..1e477751 --- /dev/null +++ b/gestioncof/templates/buro-denied.html @@ -0,0 +1,5 @@ +{% extends "base_title.html" %} + +{% block realcontent %} +

      Section réservée au Burô.

      +{% endblock %} diff --git a/gestioncof/tests/utils.py b/gestioncof/tests/utils.py index d35cb87f..7325e350 100644 --- a/gestioncof/tests/utils.py +++ b/gestioncof/tests/utils.py @@ -1,15 +1,4 @@ -import os - -from django.conf import settings from django.contrib.auth import get_user_model -from django.core.management import call_command - -from gestioncof.petits_cours_models import ( - PetitCoursAbility, - PetitCoursAttributionCounter, - PetitCoursDemande, - PetitCoursSubject, -) User = get_user_model() @@ -77,31 +66,3 @@ def create_root(username, attrs=None): attrs.setdefault("is_staff", True) attrs.setdefault("is_superuser", True) return _create_user(username, attrs=attrs) - - -def create_petitcours_ability(**kwargs): - if "user" not in kwargs: - kwargs["user"] = create_user() - if "matiere" not in kwargs: - kwargs["matiere"] = create_petitcours_subject() - if "niveau" not in kwargs: - kwargs["niveau"] = "college" - ability = PetitCoursAbility.objects.create(**kwargs) - PetitCoursAttributionCounter.get_uptodate(ability.user, ability.matiere) - return ability - - -def create_petitcours_demande(**kwargs): - return PetitCoursDemande.objects.create(**kwargs) - - -def create_petitcours_subject(**kwargs): - return PetitCoursSubject.objects.create(**kwargs) - - -class PetitCoursTestHelpers: - def require_custommails(self): - data_file = os.path.join( - settings.BASE_DIR, "gestioncof", "management", "data", "custommail.json" - ) - call_command("syncmails", data_file, verbosity=0) diff --git a/gestioncof/urls.py b/gestioncof/urls.py index c4414fa5..e1e36a17 100644 --- a/gestioncof/urls.py +++ b/gestioncof/urls.py @@ -1,8 +1,7 @@ from django.conf.urls import url -from gestioncof import petits_cours_views, views +from gestioncof import views from gestioncof.decorators import buro_required -from gestioncof.petits_cours_views import DemandeDetailView, DemandeListView export_patterns = [ url(r"^members$", views.export_members, name="cof.membres_export"), @@ -21,40 +20,6 @@ export_patterns = [ url(r"^mega$", views.export_mega, name="cof.mega_export"), ] -petitcours_patterns = [ - url( - r"^inscription$", - petits_cours_views.inscription, - name="petits-cours-inscription", - ), - url(r"^demande$", petits_cours_views.demande, name="petits-cours-demande"), - url( - r"^demande-raw$", - petits_cours_views.demande_raw, - name="petits-cours-demande-raw", - ), - url( - r"^demandes$", - buro_required(DemandeListView.as_view()), - name="petits-cours-demandes-list", - ), - url( - r"^demandes/(?P\d+)$", - buro_required(DemandeDetailView.as_view()), - name="petits-cours-demande-details", - ), - url( - r"^demandes/(?P\d+)/traitement$", - petits_cours_views.traitement, - name="petits-cours-demande-traitement", - ), - url( - r"^demandes/(?P\d+)/retraitement$", - petits_cours_views.retraitement, - name="petits-cours-demande-retraitement", - ), -] - surveys_patterns = [ url( r"^(?P\d+)/status$", diff --git a/kfet/open/tests.py b/kfet/open/tests.py index 75a9bf8a..4e652cb6 100644 --- a/kfet/open/tests.py +++ b/kfet/open/tests.py @@ -1,4 +1,5 @@ import json +import random from datetime import timedelta from unittest import mock @@ -8,7 +9,7 @@ from django.contrib.auth.models import AnonymousUser, Permission, User from django.test import Client from django.utils import timezone -from . import OpenKfet, kfet_open +from . import OpenKfet from .consumers import OpenKfetConsumer @@ -16,10 +17,10 @@ class OpenKfetTest(ChannelTestCase): """OpenKfet object unit-tests suite.""" def setUp(self): - self.kfet_open = OpenKfet() - - def tearDown(self): - self.kfet_open.clear_cache() + self.kfet_open = OpenKfet( + cache_prefix="test_kfetopen_%s" % random.randrange(2 ** 20) + ) + self.addCleanup(self.kfet_open.clear_cache) def test_defaults(self): """Default values.""" @@ -136,8 +137,14 @@ class OpenKfetViewsTest(ChannelTestCase): self.c_a = Client() self.c_a.login(username="admin", password="admin") - def tearDown(self): - kfet_open.clear_cache() + self.kfet_open = OpenKfet( + cache_prefix="test_kfetopen_%s" % random.randrange(2 ** 20) + ) + self.addCleanup(self.kfet_open.clear_cache) + + views_patcher = mock.patch("kfet.open.views.kfet_open", self.kfet_open) + views_patcher.start() + self.addCleanup(views_patcher.stop) def test_door(self): """Edit raw_status.""" @@ -146,14 +153,14 @@ class OpenKfetViewsTest(ChannelTestCase): "/k-fet/open/raw_open", {"raw_open": sent, "token": "plop"} ) self.assertEqual(200, resp.status_code) - self.assertEqual(expected, kfet_open.raw_open) + self.assertEqual(expected, self.kfet_open.raw_open) def test_force_close(self): """Edit force_close.""" for sent, expected in [(1, True), (0, False)]: resp = self.c_a.post("/k-fet/open/force_close", {"force_close": sent}) self.assertEqual(200, resp.status_code) - self.assertEqual(expected, kfet_open.force_close) + self.assertEqual(expected, self.kfet_open.force_close) def test_force_close_forbidden(self): """Can't edit force_close without kfet.can_force_close permission.""" @@ -236,8 +243,10 @@ class OpenKfetScenarioTest(ChannelTestCase): self.r_c_ws = WSClient() self.r_c_ws.force_login(self.r) - def tearDown(self): - kfet_open.clear_cache() + self.kfet_open = OpenKfet( + cache_prefix="test_kfetopen_%s" % random.randrange(2 ** 20) + ) + self.addCleanup(self.kfet_open.clear_cache) def ws_connect(self, ws_client): ws_client.send_and_consume( @@ -288,8 +297,8 @@ class OpenKfetScenarioTest(ChannelTestCase): def test_scenario_2(self): """Starting falsely closed, clients connect, disable force close.""" - kfet_open.raw_open = True - kfet_open.force_close = True + self.kfet_open.raw_open = True + self.kfet_open.force_close = True msg = self.ws_connect(self.c_ws) self.assertEqual(OpenKfet.CLOSED, msg["status"]) diff --git a/kfet/tests/test_tests_utils.py b/kfet/tests/test_tests_utils.py index 45ca2348..25046abb 100644 --- a/kfet/tests/test_tests_utils.py +++ b/kfet/tests/test_tests_utils.py @@ -1,3 +1,6 @@ +from decimal import Decimal +from unittest import mock + from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType @@ -5,9 +8,16 @@ from django.test import TestCase from gestioncof.models import CofProfile -from ..models import Account +from ..models import Account, Article, ArticleCategory, Checkout, Operation from .testcases import TestCaseMixin -from .utils import create_root, create_team, create_user, get_perms, user_add_perms +from .utils import ( + create_operation_group, + create_root, + create_team, + create_user, + get_perms, + user_add_perms, +) User = get_user_model() @@ -86,3 +96,80 @@ class PermHelpersTest(TestCaseMixin, TestCase): map(repr, [self.perm1, self.perm2, self.perm_team]), ordered=False, ) + + +class OperationHelpersTest(TestCase): + def test_create_operation_group(self): + operation_group = create_operation_group() + + on_acc = Account.objects.get(cofprofile__user__username="user") + checkout = Checkout.objects.get(name="Checkout") + self.assertDictEqual( + operation_group.__dict__, + { + "_checkout_cache": checkout, + "_on_acc_cache": on_acc, + "_state": mock.ANY, + "amount": 0, + "at": mock.ANY, + "checkout_id": checkout.pk, + "comment": "", + "id": mock.ANY, + "is_cof": False, + "on_acc_id": on_acc.pk, + "valid_by_id": None, + }, + ) + self.assertFalse(operation_group.opes.all()) + + def test_create_operation_group_with_content(self): + article_category = ArticleCategory.objects.create(name="Category") + article1 = Article.objects.create( + category=article_category, name="Article 1", price=Decimal("2.50") + ) + article2 = Article.objects.create( + category=article_category, name="Article 2", price=Decimal("4.00") + ) + operation_group = create_operation_group( + content=[ + { + "type": Operation.PURCHASE, + "amount": Decimal("-3.50"), + "article": article1, + "article_nb": 2, + }, + {"type": Operation.PURCHASE, "article": article2, "article_nb": 2}, + {"type": Operation.PURCHASE, "article": article2}, + {"type": Operation.DEPOSIT, "amount": Decimal("10.00")}, + {"type": Operation.WITHDRAW, "amount": Decimal("-1.00")}, + {"type": Operation.EDIT, "amount": Decimal("7.00")}, + ] + ) + + self.assertEqual(operation_group.amount, Decimal("0.50")) + + operation_list = list(operation_group.opes.all()) + # Passed args: with purchase, article, article_nb, amount + self.assertEqual(operation_list[0].type, Operation.PURCHASE) + self.assertEqual(operation_list[0].article, article1) + self.assertEqual(operation_list[0].article_nb, 2) + self.assertEqual(operation_list[0].amount, Decimal("-3.50")) + # Passed args: with purchase, article, article_nb; without amount + self.assertEqual(operation_list[1].type, Operation.PURCHASE) + self.assertEqual(operation_list[1].article, article2) + self.assertEqual(operation_list[1].article_nb, 2) + self.assertEqual(operation_list[1].amount, Decimal("-8.00")) + # Passed args: with purchase, article; without article_nb, amount + self.assertEqual(operation_list[2].type, Operation.PURCHASE) + self.assertEqual(operation_list[2].article, article2) + self.assertEqual(operation_list[2].article_nb, 1) + self.assertEqual(operation_list[2].amount, Decimal("-4.00")) + # Passed args: with deposit, amount + self.assertEqual(operation_list[3].type, Operation.DEPOSIT) + self.assertEqual(operation_list[3].amount, Decimal("10.00")) + # Passed args: with withdraw, amount + self.assertEqual(operation_list[4].type, Operation.WITHDRAW) + self.assertEqual(operation_list[4].amount, Decimal("-1.00")) + # Passed args: with edit, amount + self.assertEqual(operation_list[5].type, Operation.EDIT) + self.assertEqual(operation_list[5].amount, Decimal("7.00")) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index bd57b6f8..e5ccfaa1 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -28,7 +28,16 @@ from ..models import ( TransferGroup, ) from .testcases import ViewTestCaseMixin -from .utils import create_team, create_user, get_perms, user_add_perms +from .utils import ( + create_checkout, + create_checkout_statement, + create_inventory_article, + create_operation_group, + create_team, + create_user, + get_perms, + user_add_perms, +) class AccountListViewTests(ViewTestCaseMixin, TestCase): @@ -2952,6 +2961,21 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): + """ + Test cases for kpsul_cancel_operations view. + + To test valid requests, one should use '_assertResponseOk(response)' to get + hints about failure reasons, if any. + + At least one test per operation type should test the complete response and + behavior (HTTP, WebSocket, object updates, and object creations) + Other tests of the same operation type can only assert the specific + behavior differences. + + For invalid requests, response errors should be tested. + + """ + url_name = "kfet.kpsul.cancel_operations" url_expected = "/k-fet/k-psul/cancel_operations" @@ -2960,8 +2984,790 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): auth_user = "team" auth_forbidden = [None, "user"] - def test_ok(self): - pass + with_liq = True + + def setUp(self): + super(KPsulCancelOperationsViewTests, self).setUp() + + self.checkout = create_checkout(balance=Decimal("100.00")) + # An Article, price=2.5, stock=20 + self.article = Article.objects.create( + category=ArticleCategory.objects.create(name="Category"), + name="Article", + price=Decimal("2.5"), + stock=20, + ) + # An Account, trigramme=000, balance=50 + # Do not assume user is cof, nor not cof. + self.account = self.accounts["user"] + self.account.balance = Decimal("50.00") + self.account.save() + + # Mock consumer of K-Psul websocket to catch what we're sending + kpsul_consumer_patcher = mock.patch("kfet.consumers.KPsul") + self.kpsul_consumer_mock = kpsul_consumer_patcher.start() + self.addCleanup(kpsul_consumer_patcher.stop) + + def _assertResponseOk(self, response): + """ + Asserts that status code of 'response' is 200, and returns the + deserialized content of the JSONResponse. + + In case status code is not 200, it prints the content of "errors" of + the response. + + """ + json_data = json.loads(getattr(response, "content", b"{}").decode("utf-8")) + try: + self.assertEqual(response.status_code, 200) + except AssertionError as exc: + msg = "Expected response is 200, got {}. Errors: {}".format( + response.status_code, json_data.get("errors") + ) + raise AssertionError(msg) from exc + return json_data + + def test_invalid_operation_not_int(self): + data = {"operations[]": ["a"]} + resp = self.client.post(self.url, data) + + self.assertEqual(resp.status_code, 400) + json_data = json.loads(resp.content.decode("utf-8")) + self.assertEqual(json_data["errors"], {}) + + def test_invalid_operation_not_exist(self): + data = {"operations[]": ["1000"]} + resp = self.client.post(self.url, data) + + self.assertEqual(resp.status_code, 400) + json_data = json.loads(resp.content.decode("utf-8")) + self.assertEqual(json_data["errors"], {"opes_notexisting": [1000]}) + + @mock.patch("django.utils.timezone.now") + def test_purchase(self, now_mock): + now_mock.return_value = self.now + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[ + {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2} + ], + ) + operation = group.opes.get() + now_mock.return_value += timedelta(seconds=15) + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + group = OperationGroup.objects.get() + self.assertDictEqual( + group.__dict__, + { + "_state": mock.ANY, + "amount": Decimal("0.00"), + "at": mock.ANY, + "checkout_id": self.checkout.pk, + "comment": "", + "id": mock.ANY, + "is_cof": False, + "on_acc_id": self.account.pk, + "valid_by_id": None, + }, + ) + operation = Operation.objects.get() + self.assertDictEqual( + operation.__dict__, + { + "_state": mock.ANY, + "addcost_amount": None, + "addcost_for_id": None, + "amount": Decimal("-5.00"), + "article_id": self.article.pk, + "article_nb": 2, + "canceled_at": self.now + timedelta(seconds=15), + "canceled_by_id": None, + "group_id": group.pk, + "id": mock.ANY, + "type": Operation.PURCHASE, + }, + ) + + self.assertDictEqual( + json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + ) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("55.00")) + self.article.refresh_from_db() + self.assertEqual(self.article.stock, 22) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("100.00")) + + self.kpsul_consumer_mock.group_send.assert_called_with( + "kfet.kpsul", + { + "opegroups": [ + { + "cancellation": True, + "id": group.pk, + "amount": Decimal("0.00"), + "is_cof": False, + } + ], + "opes": [ + { + "cancellation": True, + "id": operation.pk, + "canceled_by__trigramme": None, + "canceled_at": self.now + timedelta(seconds=15), + } + ], + "checkouts": [], + "articles": [{"id": self.article.pk, "stock": 22}], + }, + ) + + def test_purchase_with_addcost(self): + # TODO(AD): L'état de la balance du compte destinataire de la majoration ne + # devrait pas empêcher l'annulation d'une opération. + addcost_user = create_user( + "addcost", "ADD", account_attrs={"balance": Decimal("10.00")} + ) + addcost_account = addcost_user.profile.account_kfet + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[ + { + "type": Operation.PURCHASE, + "article": self.article, + "article_nb": 2, + "amount": Decimal("-6.00"), + "addcost_amount": Decimal("1.00"), + "addcost_for": addcost_account, + } + ], + ) + operation = group.opes.get() + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + self._assertResponseOk(resp) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("56.00")) + addcost_account.refresh_from_db() + self.assertEqual(addcost_account.balance, Decimal("9.00")) + + def test_purchase_cash(self): + group = create_operation_group( + on_acc=self.accounts["liq"], + checkout=self.checkout, + content=[ + { + "type": Operation.PURCHASE, + "article": self.article, + "article_nb": 2, + "amount": Decimal("-5.00"), + } + ], + ) + operation = group.opes.get() + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + self._assertResponseOk(resp) + + self.assertEqual(self.accounts["liq"].balance, Decimal("0.00")) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("95.00")) + + ws_data_checkouts = self.kpsul_consumer_mock.group_send.call_args[0][1][ + "checkouts" + ] + self.assertListEqual( + ws_data_checkouts, [{"id": self.checkout.pk, "balance": Decimal("95.00")}] + ) + + def test_purchase_cash_with_addcost(self): + # TODO(AD): L'état de la balance du compte destinataire de la majoration ne + # devrait pas empêcher l'annulation d'une opération. + addcost_user = create_user( + "addcost", "ADD", account_attrs={"balance": Decimal("10.00")} + ) + addcost_account = addcost_user.profile.account_kfet + group = create_operation_group( + on_acc=self.accounts["liq"], + checkout=self.checkout, + content=[ + { + "type": Operation.PURCHASE, + "article": self.article, + "article_nb": 2, + "amount": Decimal("-6.00"), + "addcost_amount": Decimal("1.00"), + "addcost_for": addcost_account, + } + ], + ) + operation = group.opes.get() + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + self._assertResponseOk(resp) + + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("94.00")) + addcost_account.refresh_from_db() + self.assertEqual(addcost_account.balance, Decimal("9.00")) + + ws_data_checkouts = self.kpsul_consumer_mock.group_send.call_args[0][1][ + "checkouts" + ] + self.assertListEqual( + ws_data_checkouts, [{"id": self.checkout.pk, "balance": Decimal("94.00")}] + ) + + @mock.patch("django.utils.timezone.now") + def test_deposit(self, now_mock): + now_mock.return_value = self.now + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[{"type": Operation.DEPOSIT, "amount": Decimal("10.75")}], + ) + operation = group.opes.get() + now_mock.return_value += timedelta(seconds=15) + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + group = OperationGroup.objects.get() + self.assertDictEqual( + group.__dict__, + { + "_state": mock.ANY, + "amount": Decimal("0.00"), + "at": mock.ANY, + "checkout_id": self.checkout.pk, + "comment": "", + "id": mock.ANY, + "is_cof": False, + "on_acc_id": self.account.pk, + "valid_by_id": None, + }, + ) + operation = Operation.objects.get() + self.assertDictEqual( + operation.__dict__, + { + "_state": mock.ANY, + "addcost_amount": None, + "addcost_for_id": None, + "amount": Decimal("10.75"), + "article_id": None, + "article_nb": None, + "canceled_at": self.now + timedelta(seconds=15), + "canceled_by_id": None, + "group_id": group.pk, + "id": mock.ANY, + "type": Operation.DEPOSIT, + }, + ) + + self.assertDictEqual( + json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + ) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("39.25")) + self.article.refresh_from_db() + self.assertEqual(self.article.stock, 20) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("89.25")) + + self.kpsul_consumer_mock.group_send.assert_called_with( + "kfet.kpsul", + { + "opegroups": [ + { + "cancellation": True, + "id": group.pk, + "amount": Decimal("0.00"), + "is_cof": False, + } + ], + "opes": [ + { + "cancellation": True, + "id": operation.pk, + "canceled_by__trigramme": None, + "canceled_at": self.now + timedelta(seconds=15), + } + ], + "checkouts": [{"id": self.checkout.pk, "balance": Decimal("89.25")}], + "articles": [], + }, + ) + + @mock.patch("django.utils.timezone.now") + def test_withdraw(self, now_mock): + now_mock.return_value = self.now + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[{"type": Operation.WITHDRAW, "amount": Decimal("-10.75")}], + ) + operation = group.opes.get() + now_mock.return_value += timedelta(seconds=15) + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + group = OperationGroup.objects.get() + self.assertDictEqual( + group.__dict__, + { + "_state": mock.ANY, + "amount": Decimal("0.00"), + "at": mock.ANY, + "checkout_id": self.checkout.pk, + "comment": "", + "id": mock.ANY, + "is_cof": False, + "on_acc_id": self.account.pk, + "valid_by_id": None, + }, + ) + operation = Operation.objects.get() + self.assertDictEqual( + operation.__dict__, + { + "_state": mock.ANY, + "addcost_amount": None, + "addcost_for_id": None, + "amount": Decimal("-10.75"), + "article_id": None, + "article_nb": None, + "canceled_at": self.now + timedelta(seconds=15), + "canceled_by_id": None, + "group_id": group.pk, + "id": mock.ANY, + "type": Operation.WITHDRAW, + }, + ) + + self.assertDictEqual( + json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + ) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("60.75")) + self.article.refresh_from_db() + self.assertEqual(self.article.stock, 20) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("110.75")) + + self.kpsul_consumer_mock.group_send.assert_called_with( + "kfet.kpsul", + { + "opegroups": [ + { + "cancellation": True, + "id": group.pk, + "amount": Decimal("0.00"), + "is_cof": False, + } + ], + "opes": [ + { + "cancellation": True, + "id": operation.pk, + "canceled_by__trigramme": None, + "canceled_at": self.now + timedelta(seconds=15), + } + ], + "checkouts": [{"id": self.checkout.pk, "balance": Decimal("110.75")}], + "articles": [], + }, + ) + + @mock.patch("django.utils.timezone.now") + def test_edit(self, now_mock): + now_mock.return_value = self.now + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[{"type": Operation.EDIT, "amount": Decimal("-10.75")}], + ) + operation = group.opes.get() + now_mock.return_value += timedelta(seconds=15) + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + group = OperationGroup.objects.get() + self.assertDictEqual( + group.__dict__, + { + "_state": mock.ANY, + "amount": Decimal("0.00"), + "at": mock.ANY, + "checkout_id": self.checkout.pk, + "comment": "", + "id": mock.ANY, + "is_cof": False, + "on_acc_id": self.account.pk, + "valid_by_id": None, + }, + ) + operation = Operation.objects.get() + self.assertDictEqual( + operation.__dict__, + { + "_state": mock.ANY, + "addcost_amount": None, + "addcost_for_id": None, + "amount": Decimal("-10.75"), + "article_id": None, + "article_nb": None, + "canceled_at": self.now + timedelta(seconds=15), + "canceled_by_id": None, + "group_id": group.pk, + "id": mock.ANY, + "type": Operation.EDIT, + }, + ) + + self.assertDictEqual( + json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + ) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("60.75")) + self.article.refresh_from_db() + self.assertEqual(self.article.stock, 20) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("100.00")) + + self.kpsul_consumer_mock.group_send.assert_called_with( + "kfet.kpsul", + { + "opegroups": [ + { + "cancellation": True, + "id": group.pk, + "amount": Decimal("0.00"), + "is_cof": False, + } + ], + "opes": [ + { + "cancellation": True, + "id": operation.pk, + "canceled_by__trigramme": None, + "canceled_at": self.now + timedelta(seconds=15), + } + ], + "checkouts": [], + "articles": [], + }, + ) + + @mock.patch("django.utils.timezone.now") + def test_old_operations(self, now_mock): + kfet_config.set(cancel_duration=timedelta(minutes=10)) + user_add_perms(self.users["team"], ["kfet.cancel_old_operations"]) + now_mock.return_value = self.now + group = create_operation_group( + at=self.now, + on_acc=self.account, + checkout=self.checkout, + content=[{"type": Operation.WITHDRAW, "amount": Decimal("-10.75")}], + ) + operation = group.opes.get() + now_mock.return_value += timedelta(minutes=10, seconds=1) + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + self.assertEqual(len(json_data["canceled"]), 1) + + @mock.patch("django.utils.timezone.now") + def test_invalid_old_operations_requires_perm(self, now_mock): + kfet_config.set(cancel_duration=timedelta(minutes=10)) + now_mock.return_value = self.now + group = create_operation_group( + at=self.now, + on_acc=self.account, + checkout=self.checkout, + content=[{"type": Operation.WITHDRAW, "amount": Decimal("-10.75")}], + ) + operation = group.opes.get() + now_mock.return_value += timedelta(minutes=10, seconds=1) + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + self.assertEqual(resp.status_code, 403) + json_data = json.loads(resp.content.decode("utf-8")) + self.assertEqual( + json_data["errors"], + {"missing_perms": ["Annuler des commandes non récentes"]}, + ) + + def test_already_canceled(self): + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[ + { + "type": Operation.WITHDRAW, + "amount": Decimal("-10.75"), + "canceled_at": timezone.now(), + } + ], + ) + operation = group.opes.get() + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + self.assertDictEqual( + json_data["warnings"], {"already_canceled": [operation.pk]} + ) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("50.00")) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("100.00")) + + @mock.patch("django.utils.timezone.now") + def test_checkout_before_last_statement(self, now_mock): + now_mock.return_value = self.now + group = create_operation_group( + at=self.now, + on_acc=self.account, + checkout=self.checkout, + content=[{"type": Operation.WITHDRAW, "amount": Decimal("-10.75")}], + ) + operation = group.opes.get() + now_mock.return_value += timedelta(seconds=30) + create_checkout_statement(checkout=self.checkout) + now_mock.return_value += timedelta(seconds=30) + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + self.assertEqual(len(json_data["canceled"]), 1) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("60.75")) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("100.00")) + + @mock.patch("django.utils.timezone.now") + def test_article_before_last_inventory(self, now_mock): + now_mock.return_value = self.now + group = create_operation_group( + at=self.now, + on_acc=self.account, + checkout=self.checkout, + content=[ + {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2} + ], + ) + operation = group.opes.get() + now_mock.return_value += timedelta(seconds=30) + create_inventory_article(article=self.article) + now_mock.return_value += timedelta(seconds=30) + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + self.assertEqual(len(json_data["canceled"]), 1) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("55.00")) + self.article.refresh_from_db() + self.assertEqual(self.article.stock, 20) + + def test_negative(self): + kfet_config.set(overdraft_amount=Decimal("40.00")) + user_add_perms(self.users["team"], ["kfet.perform_negative_operations"]) + self.account.balance = Decimal("-20.00") + self.account.save() + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[{"type": Operation.DEPOSIT, "amount": Decimal("10.75")}], + ) + operation = group.opes.get() + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + self.assertEqual(len(json_data["canceled"]), 1) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("-30.75")) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("89.25")) + + def test_invalid_negative_above_thresholds(self): + kfet_config.set(overdraft_amount=Decimal("5.00")) + user_add_perms(self.users["team"], ["kfet.perform_negative_operations"]) + self.account.balance = Decimal("-20.00") + self.account.save() + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[{"type": Operation.DEPOSIT, "amount": Decimal("10.75")}], + ) + operation = group.opes.get() + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + self.assertEqual(resp.status_code, 403) + json_data = json.loads(resp.content.decode("utf-8")) + self.assertEqual(json_data["errors"], {"negative": [self.account.trigramme]}) + + def test_invalid_negative_requires_perms(self): + kfet_config.set(overdraft_amount=Decimal("40.00")) + self.account.balance = Decimal("-20.00") + self.account.save() + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[{"type": Operation.DEPOSIT, "amount": Decimal("10.75")}], + ) + operation = group.opes.get() + + data = {"operations[]": [str(operation.pk)]} + resp = self.client.post(self.url, data) + + self.assertEqual(resp.status_code, 403) + json_data = json.loads(resp.content.decode("utf-8")) + self.assertEqual( + json_data["errors"], + {"missing_perms": ["Enregistrer des commandes en négatif"]}, + ) + + def test_partial_0(self): + group = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[ + {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2}, + {"type": Operation.DEPOSIT, "amount": Decimal("10.75")}, + {"type": Operation.EDIT, "amount": Decimal("-6.00")}, + { + "type": Operation.WITHDRAW, + "amount": Decimal("-10.75"), + "canceled_at": timezone.now(), + }, + ], + ) + operation1 = group.opes.get(type=Operation.PURCHASE) + operation2 = group.opes.get(type=Operation.EDIT) + operation3 = group.opes.get(type=Operation.WITHDRAW) + + data = { + "operations[]": [str(operation1.pk), str(operation2.pk), str(operation3.pk)] + } + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + group.refresh_from_db() + self.assertEqual(group.amount, Decimal("10.75")) + self.assertEqual(group.opes.exclude(canceled_at=None).count(), 3) + + self.assertDictEqual( + json_data, + { + "canceled": [operation1.pk, operation2.pk], + "warnings": {"already_canceled": [operation3.pk]}, + "errors": {}, + }, + ) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("61.00")) + self.article.refresh_from_db() + self.assertEqual(self.article.stock, 22) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("100.00")) + + def test_multi_0(self): + group1 = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[ + {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2}, + {"type": Operation.DEPOSIT, "amount": Decimal("10.75")}, + {"type": Operation.EDIT, "amount": Decimal("-6.00")}, + ], + ) + operation11 = group1.opes.get(type=Operation.PURCHASE) + group2 = create_operation_group( + on_acc=self.account, + checkout=self.checkout, + content=[ + {"type": Operation.PURCHASE, "article": self.article, "article_nb": 5}, + {"type": Operation.DEPOSIT, "amount": Decimal("3.00")}, + ], + ) + operation21 = group2.opes.get(type=Operation.PURCHASE) + operation22 = group2.opes.get(type=Operation.DEPOSIT) + + data = { + "operations[]": [ + str(operation11.pk), + str(operation21.pk), + str(operation22.pk), + ] + } + resp = self.client.post(self.url, data) + + json_data = self._assertResponseOk(resp) + + group1.refresh_from_db() + self.assertEqual(group1.amount, Decimal("4.75")) + self.assertEqual(group1.opes.exclude(canceled_at=None).count(), 1) + group2.refresh_from_db() + self.assertEqual(group2.amount, Decimal(0)) + self.assertEqual(group2.opes.exclude(canceled_at=None).count(), 2) + + self.assertEqual(len(json_data["canceled"]), 3) + + self.account.refresh_from_db() + self.assertEqual(self.account.balance, Decimal("64.50")) + self.article.refresh_from_db() + self.assertEqual(self.article.stock, 27) + self.checkout.refresh_from_db() + self.assertEqual(self.checkout.balance, Decimal("97.00")) class KPsulArticlesData(ViewTestCaseMixin, TestCase): diff --git a/kfet/tests/utils.py b/kfet/tests/utils.py index f1b6933a..79ca1b5e 100644 --- a/kfet/tests/utils.py +++ b/kfet/tests/utils.py @@ -1,7 +1,21 @@ +from datetime import timedelta +from decimal import Decimal + from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.utils import timezone -from ..models import Account +from ..models import ( + Account, + Article, + ArticleCategory, + Checkout, + CheckoutStatement, + Inventory, + InventoryArticle, + Operation, + OperationGroup, +) User = get_user_model() @@ -184,3 +198,180 @@ def user_add_perms(user, perms_labels): # it to avoid using of the previous permissions cache. # https://docs.djangoproject.com/en/dev/topics/auth/default/#permission-caching return User.objects.get(pk=user.pk) + + +def create_checkout(**kwargs): + """ + Factory to create a checkout. + See defaults for unpassed arguments in code below. + """ + if "created_by" not in kwargs or "created_by_id" not in kwargs: + try: + team_account = Account.objects.get(cofprofile__user__username="team") + except Account.DoesNotExist: + team_account = create_team().profile.account_kfet + kwargs["created_by"] = team_account + kwargs.setdefault("name", "Checkout") + kwargs.setdefault("valid_from", timezone.now() - timedelta(days=14)) + kwargs.setdefault("valid_to", timezone.now() - timedelta(days=14)) + + return Checkout.objects.create(**kwargs) + + +def create_operation_group(content=None, **kwargs): + """ + Factory to create an OperationGroup and a set of related Operation. + + It aims to get objects for testing purposes with minimal setup, and + preserving consistency. + For this, it uses, and creates if necessary, default objects for unpassed + arguments. + + Args: + content: list of dict + Describe set of Operation to create along the OperationGroup. + Each item is passed to the Operation factory. + kwargs: + Used to control OperationGroup creation. + + """ + if content is None: + content = [] + + # Prepare OperationGroup creation. + + # Set 'checkout' for OperationGroup if unpassed. + if "checkout" not in kwargs and "checkout_id" not in kwargs: + try: + checkout = Checkout.objects.get(name="Checkout") + except Checkout.DoesNotExist: + checkout = create_checkout() + kwargs["checkout"] = checkout + + # Set 'on_acc' for OperationGroup if unpassed. + if "on_acc" not in kwargs and "on_acc_id" not in kwargs: + try: + on_acc = Account.objects.get(cofprofile__user__username="user") + except Account.DoesNotExist: + on_acc = create_user().profile.account_kfet + kwargs["on_acc"] = on_acc + + # Set 'is_cof' for OperationGroup if unpassed. + if "is_cof" not in kwargs: + # Use current is_cof status of 'on_acc'. + kwargs["is_cof"] = kwargs["on_acc"].cofprofile.is_cof + + # Create OperationGroup. + group = OperationGroup.objects.create(**kwargs) + + # We can now create objects referencing this OperationGroup. + + # Process set of related Operation. + if content: + # Create them. + operation_list = [] + for operation_kwargs in content: + operation = create_operation(group=group, **operation_kwargs) + operation_list.append(operation) + + # Update OperationGroup accordingly, for consistency. + for operation in operation_list: + if not operation.canceled_at: + group.amount += operation.amount + group.save() + + return group + + +def create_operation(**kwargs): + """ + Factory to create an Operation for testing purposes. + + If you give a 'group' (OperationGroup), it won't update it, you have do + this "manually". Prefer using OperationGroup factory to get a consistent + group with operations. + + """ + if "group" not in kwargs and "group_id" not in kwargs: + # To get a consistent OperationGroup (amount...) for the operation + # in-creation, prefer using create_operation_group factory with + # 'content'. + kwargs["group"] = create_operation_group() + + if "type" not in kwargs: + raise RuntimeError("Can't create an Operation without 'type'.") + + # Apply defaults for purchase + if kwargs["type"] == Operation.PURCHASE: + if "article" not in kwargs: + raise NotImplementedError( + "One could write a create_article factory. Right now, you must" + "pass an 'article'." + ) + + # Unpassed 'article_nb' defaults to 1. + kwargs.setdefault("article_nb", 1) + + # Unpassed 'amount' will use current article price and quantity. + if "amount" not in kwargs: + if "addcost_for" in kwargs or "addcost_amount" in kwargs: + raise NotImplementedError( + "One could handle the case where 'amount' is missing and " + "addcost applies. Right now, please pass an 'amount'." + ) + kwargs["amount"] = -kwargs["article"].price * kwargs["article_nb"] + + return Operation.objects.create(**kwargs) + + +def create_checkout_statement(**kwargs): + if "checkout" not in kwargs: + kwargs["checkout"] = create_checkout() + if "by" not in kwargs: + try: + team_account = Account.objects.get(cofprofile__user__username="team") + except Account.DoesNotExist: + team_account = create_team().profile.account_kfet + kwargs["by"] = team_account + kwargs.setdefault("balance_new", kwargs["checkout"].balance) + kwargs.setdefault("balance_old", kwargs["checkout"].balance) + kwargs.setdefault("amount_taken", Decimal(0)) + + return CheckoutStatement.objects.create(**kwargs) + + +def create_article(**kwargs): + kwargs.setdefault("name", "Article") + kwargs.setdefault("price", Decimal("2.50")) + kwargs.setdefault("stock", 20) + if "category" not in kwargs: + kwargs["category"] = create_article_category() + + return Article.objects.create(**kwargs) + + +def create_article_category(**kwargs): + kwargs.setdefault("name", "Category") + return ArticleCategory.objects.create(**kwargs) + + +def create_inventory(**kwargs): + if "by" not in kwargs: + try: + team_account = Account.objects.get(cofprofile__user__username="team") + except Account.DoesNotExist: + team_account = create_team().profile.account_kfet + kwargs["by"] = team_account + + return Inventory.objects.create(**kwargs) + + +def create_inventory_article(**kwargs): + if "inventory" not in kwargs: + kwargs["inventory"] = create_inventory() + if "article" not in kwargs: + kwargs["article"] = create_article() + kwargs.setdefault("stock_old", kwargs["article"].stock) + kwargs.setdefault("stock_new", kwargs["article"].stock) + + return InventoryArticle.objects.create(**kwargs) diff --git a/kfet/views.py b/kfet/views.py index a2a69930..51bfc1a4 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1301,13 +1301,27 @@ def kpsul_cancel_operations(request): stock=F("stock") + to_articles_stocks[article] ) + # Need refresh from db cause we used update on querysets. + # Sort objects by pk to get deterministic responses. + opegroups_pk = [opegroup.pk for opegroup in to_groups_amounts] + opegroups = ( + OperationGroup.objects.values("id", "amount", "is_cof") + .filter(pk__in=opegroups_pk) + .order_by("pk") + ) + opes = sorted(opes) + checkouts_pk = [checkout.pk for checkout in to_checkouts_balances] + checkouts = ( + Checkout.objects.values("id", "balance") + .filter(pk__in=checkouts_pk) + .order_by("pk") + ) + articles_pk = [article.pk for articles in to_articles_stocks] + articles = Article.objects.values("id", "stock").filter(pk__in=articles_pk) + # Websocket data websocket_data = {"opegroups": [], "opes": [], "checkouts": [], "articles": []} - # Need refresh from db cause we used update on querysets - opegroups_pk = [opegroup.pk for opegroup in to_groups_amounts] - opegroups = OperationGroup.objects.values("id", "amount", "is_cof").filter( - pk__in=opegroups_pk - ) + for opegroup in opegroups: websocket_data["opegroups"].append( { @@ -1327,16 +1341,10 @@ def kpsul_cancel_operations(request): "canceled_at": canceled_at, } ) - # Need refresh from db cause we used update on querysets - checkouts_pk = [checkout.pk for checkout in to_checkouts_balances] - checkouts = Checkout.objects.values("id", "balance").filter(pk__in=checkouts_pk) for checkout in checkouts: websocket_data["checkouts"].append( {"id": checkout["id"], "balance": checkout["balance"]} ) - # Need refresh from db cause we used update on querysets - articles_pk = [article.pk for articles in to_articles_stocks] - articles = Article.objects.values("id", "stock").filter(pk__in=articles_pk) for article in articles: websocket_data["articles"].append( {"id": article["id"], "stock": article["stock"]} diff --git a/petitscours/__init__.py b/petitscours/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gestioncof/petits_cours_forms.py b/petitscours/forms.py similarity index 95% rename from gestioncof/petits_cours_forms.py rename to petitscours/forms.py index b9cfc067..5309b41d 100644 --- a/gestioncof/petits_cours_forms.py +++ b/petitscours/forms.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from django.forms import ModelForm from django.forms.models import BaseInlineFormSet, inlineformset_factory -from gestioncof.petits_cours_models import PetitCoursAbility, PetitCoursDemande +from petitscours.models import PetitCoursAbility, PetitCoursDemande class BaseMatieresFormSet(BaseInlineFormSet): diff --git a/gestioncof/petits_cours_models.py b/petitscours/models.py similarity index 77% rename from gestioncof/petits_cours_models.py rename to petitscours/models.py index 40031877..c3bdce2f 100644 --- a/gestioncof/petits_cours_models.py +++ b/petitscours/models.py @@ -3,6 +3,7 @@ from functools import reduce from django.contrib.auth.models import User from django.db import models from django.db.models import Min +from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ @@ -16,6 +17,7 @@ LEVELS_CHOICES = ( ("prepa1styear", _("Prépa 1ère année / L1")), ("prepa2ndyear", _("Prépa 2ème année / L2")), ("licence3", _("Licence 3")), + ("master1", _("Master (1ère ou 2ème année)")), ("other", _("Autre (préciser dans les commentaires)")), ) @@ -27,6 +29,7 @@ class PetitCoursSubject(models.Model): ) class Meta: + app_label = "gestioncof" verbose_name = "Matière de petits cours" verbose_name_plural = "Matières des petits cours" @@ -45,6 +48,7 @@ class PetitCoursAbility(models.Model): agrege = models.BooleanField(_("Agrégé"), default=False) class Meta: + app_label = "gestioncof" verbose_name = "Compétence petits cours" verbose_name_plural = "Compétences des petits cours" @@ -53,6 +57,12 @@ class PetitCoursAbility(models.Model): self.user.username, self.matiere, self.niveau ) + @cached_property + def counter(self) -> int: + """Le compteur d'attribution associé au professeur pour cette matière.""" + + return PetitCoursAttributionCounter.get_uptodate(self.user, self.matiere).count + class PetitCoursDemande(models.Model): name = models.CharField(_("Nom/prénom"), max_length=200) @@ -126,7 +136,44 @@ class PetitCoursDemande(models.Model): candidates = candidates.order_by("?").select_related().all() yield (matiere, candidates) + def get_proposals(self, *, max_candidates: int = None, redo: bool = False): + """Calcule une proposition de profs pour la demande. + + Args: + max_candidates (optionnel; défaut: `None`): Le nombre maximum de + candidats à proposer par demande. Si `None` ou non spécifié, + il n'y a pas de limite. + + redo (optionel; défaut: `False`): Détermine si on re-calcule les + propositions pour la demande (les professeurs à qui on a déjà + proposé cette demande sont exclus). + + Returns: + proposals: Le dictionnaire qui associe à chaque matière la liste + des professeurs proposés. Les matières pour lesquelles aucun + professeur n'est disponible ne sont pas présentes dans + `proposals`. + unsatisfied: La liste des matières pour lesquelles aucun + professeur n'est disponible. + """ + + proposals = {} + unsatisfied = [] + for matiere, candidates in self.get_candidates(redo=redo): + if not candidates: + unsatisfied.append(matiere) + else: + proposals[matiere] = matiere_proposals = [] + + candidates = sorted(candidates, key=lambda c: c.counter) + candidates = candidates[:max_candidates] + for candidate in candidates[:max_candidates]: + matiere_proposals.append(candidate.user) + + return proposals, unsatisfied + class Meta: + app_label = "gestioncof" verbose_name = "Demande de petits cours" verbose_name_plural = "Demandes de petits cours" @@ -147,6 +194,7 @@ class PetitCoursAttribution(models.Model): selected = models.BooleanField(_("Sélectionné par le demandeur"), default=False) class Meta: + app_label = "gestioncof" verbose_name = "Attribution de petits cours" verbose_name_plural = "Attributions de petits cours" @@ -182,6 +230,7 @@ class PetitCoursAttributionCounter(models.Model): return counter class Meta: + app_label = "gestioncof" verbose_name = "Compteur d'attribution de petits cours" verbose_name_plural = "Compteurs d'attributions de petits cours" diff --git a/gestioncof/templates/base_title_petitscours.html b/petitscours/templates/petitscours/base_title.html similarity index 100% rename from gestioncof/templates/base_title_petitscours.html rename to petitscours/templates/petitscours/base_title.html diff --git a/gestioncof/templates/demande-petit-cours.html b/petitscours/templates/petitscours/demande.html similarity index 100% rename from gestioncof/templates/demande-petit-cours.html rename to petitscours/templates/petitscours/demande.html diff --git a/gestioncof/templates/gestioncof/details_demande_petit_cours.html b/petitscours/templates/petitscours/demande_detail.html similarity index 93% rename from gestioncof/templates/gestioncof/details_demande_petit_cours.html rename to petitscours/templates/petitscours/demande_detail.html index b51c0dc0..e82a67d4 100644 --- a/gestioncof/templates/gestioncof/details_demande_petit_cours.html +++ b/petitscours/templates/petitscours/demande_detail.html @@ -1,11 +1,11 @@ -{% extends "base_title_petitscours.html" %} +{% extends "petitscours/base_title.html" %} {% load staticfiles %} {% block page_size %}col-sm-8{% endblock %} {% block realcontent %}

      Demande de petits cours

      - {% include "details_demande_petit_cours_infos.html" %} + {% include "petitscours/details_demande_infos.html" %}
      diff --git a/gestioncof/templates/petits_cours_demandes_list.html b/petitscours/templates/petitscours/demande_list.html similarity index 97% rename from gestioncof/templates/petits_cours_demandes_list.html rename to petitscours/templates/petitscours/demande_list.html index 92e934f5..74654e44 100644 --- a/gestioncof/templates/petits_cours_demandes_list.html +++ b/petitscours/templates/petitscours/demande_list.html @@ -1,4 +1,4 @@ -{% extends "base_title_petitscours.html" %} +{% extends "petitscours/base_title.html" %} {% load staticfiles %} {% block realcontent %} diff --git a/gestioncof/templates/demande-petit-cours-raw.html b/petitscours/templates/petitscours/demande_raw.html similarity index 100% rename from gestioncof/templates/demande-petit-cours-raw.html rename to petitscours/templates/petitscours/demande_raw.html diff --git a/gestioncof/templates/details_demande_petit_cours_infos.html b/petitscours/templates/petitscours/details_demande_infos.html similarity index 100% rename from gestioncof/templates/details_demande_petit_cours_infos.html rename to petitscours/templates/petitscours/details_demande_infos.html diff --git a/gestioncof/templates/inscription-petit-cours.html b/petitscours/templates/petitscours/inscription.html similarity index 98% rename from gestioncof/templates/inscription-petit-cours.html rename to petitscours/templates/petitscours/inscription.html index 4ac0a874..c4920fb3 100644 --- a/gestioncof/templates/inscription-petit-cours.html +++ b/petitscours/templates/petitscours/inscription.html @@ -94,7 +94,7 @@ var django = { {% csrf_token %}
      Recevoir des propositions de petits cours
      - {% include "inscription-petit-cours-formset.html" %} + {% include "petitscours/inscription_formset.html" %}
      {% endblock %} diff --git a/bda/templates/bda/revente/subscribe.html b/bda/templates/bda/revente/subscribe.html index 5db11d78..fbea28b5 100644 --- a/bda/templates/bda/revente/subscribe.html +++ b/bda/templates/bda/revente/subscribe.html @@ -53,8 +53,13 @@ $(function(){ $("table.stupidtable").stupidtable(); }); + $("tr").click(function() { $(this).find("input[type=checkbox]").click() - }) + }); + + $("input[type=checkbox]").click(function(e) { + e.stopPropagation(); + }); {% endblock %} diff --git a/bda/templates/bda/revente/tirages.html b/bda/templates/bda/revente/tirages.html index 1d72e9b5..4e5c2cfd 100644 --- a/bda/templates/bda/revente/tirages.html +++ b/bda/templates/bda/revente/tirages.html @@ -86,9 +86,14 @@ $(function(){ $("table.stupidtable").stupidtable(); }); + $("tr").click(function() { $(this).find("input[type=checkbox]").click() - }) + }); + + $("input[type=checkbox]").click(function(e) { + e.stopPropagation(); + });
      Traitée