From 4bd2562edf5a82c6d58a4c805bdbe4ff31483b6b Mon Sep 17 00:00:00 2001 From: Hugo Roussille Date: Wed, 13 Sep 2017 15:57:57 +0200 Subject: [PATCH 01/84] django-cors-headers for cross-domain AJAX --- cof/settings/common.py | 2 ++ requirements.txt | 1 + 2 files changed, 3 insertions(+) diff --git a/cof/settings/common.py b/cof/settings/common.py index ba0b6044..fba32743 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -91,9 +91,11 @@ INSTALLED_APPS = [ 'modelcluster', 'taggit', 'kfet.cms', + 'corsheaders', ] MIDDLEWARE_CLASSES = [ + 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', diff --git a/requirements.txt b/requirements.txt index f3964212..b28b939e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ channels==1.1.5 python-dateutil wagtail==1.10.* wagtailmenus==2.2.* +django-cors-headers==2.1.0 # Production tools wheel From a4eedbc1a60c902301ef48559a58caf804ad5624 Mon Sep 17 00:00:00 2001 From: Hugo Roussille Date: Wed, 13 Sep 2017 18:21:34 +0200 Subject: [PATCH 02/84] Whitelist bda and cof apps for cross-domain --- cof/settings/common.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cof/settings/common.py b/cof/settings/common.py index fba32743..bac17653 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -197,6 +197,11 @@ AUTHENTICATION_BACKENDS = ( RECAPTCHA_USE_SSL = True +CORS_ORIGIN_REGEX_WHITELIST = ( + 'bda.ens.fr', + 'cof.ens.fr', +) + # Cache settings CACHES = { From 732e47707e96bb7047438edb1791511e89918fa1 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Oct 2017 17:25:58 +0200 Subject: [PATCH 03/84] Add unsubscribe option + list of current draws --- bda/forms.py | 41 +++++++++++++++++++-- bda/templates/bda/revente-tirages.html | 28 +++++++++++++++ bda/urls.py | 11 +++--- bda/views.py | 50 +++++++++++++++++++++++++- gestioncof/templates/home.html | 7 ++-- 5 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 bda/templates/bda/revente-tirages.html diff --git a/bda/forms.py b/bda/forms.py index c0417d1e..139ef45d 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -4,7 +4,7 @@ from django import forms from django.forms.models import BaseInlineFormSet from django.utils import timezone -from bda.models import Attribution, Spectacle +from bda.models import Attribution, Spectacle, SpectacleRevente class InscriptionInlineFormSet(BaseInlineFormSet): @@ -45,6 +45,9 @@ class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, obj): return "%s" % str(obj.spectacle) +class ReventeModelMultipleChoiceField(forms.ModelMultipleChoiceField): + def label_from_instance(self, obj): + return "%s" % str(obj.attribution.spectacle) class ResellForm(forms.Form): attributions = AttributionModelMultipleChoiceField( @@ -63,7 +66,6 @@ class ResellForm(forms.Form): 'participant__user') ) - class AnnulForm(forms.Form): attributions = AttributionModelMultipleChoiceField( label='', @@ -83,7 +85,6 @@ class AnnulForm(forms.Form): 'participant__user') ) - class InscriptionReventeForm(forms.Form): spectacles = forms.ModelMultipleChoiceField( queryset=Spectacle.objects.none(), @@ -98,6 +99,40 @@ class InscriptionReventeForm(forms.Form): .filter(date__gte=timezone.now()) ) +class ReventeTirageAnnulForm(forms.Form): + reventes = ReventeModelMultipleChoiceField( + label='', + queryset=SpectacleRevente.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=False + ) + + def __init__(self, participant, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['reventes'].queryset = ( + participant.wanted.filter(soldTo__isnull=True) + .select_related('attribution__spectacle') + ) + + +class ReventeTirageForm(forms.Form): + reventes = ReventeModelMultipleChoiceField( + 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(answered_mail=participant) + .select_related('attribution__spectacle') + ) class SoldForm(forms.Form): attributions = AttributionModelMultipleChoiceField( diff --git a/bda/templates/bda/revente-tirages.html b/bda/templates/bda/revente-tirages.html new file mode 100644 index 00000000..bd738673 --- /dev/null +++ b/bda/templates/bda/revente-tirages.html @@ -0,0 +1,28 @@ +{% extends "base_title.html" %} +{% load bootstrap %} + +{% block realcontent %} + +

Tirages au sort de reventes

+{% if annulform.reventes %} +

Mes inscriptions

+
+ {% csrf_token %} + {{annulform|bootstrap}} +
+ +
+
+{% endif %} +
+{% if subform.reventes %} +

Tirages en cours

+
+ {% csrf_token %} + {{subform|bootstrap}} +
+ +
+
+{% endif %} +{% endblock %} diff --git a/bda/urls.py b/bda/urls.py index 876c84ea..51dd4235 100644 --- a/bda/urls.py +++ b/bda/urls.py @@ -32,16 +32,19 @@ urlpatterns = [ url(r'^spectacles/unpaid/(?P\d+)$', views.unpaid, name="bda-unpaid"), - url(r'^liste-revente/(?P\d+)$', + url(r'^revente/(?P\d+)/list$', views.list_revente, name="bda-liste-revente"), - url(r'^buy-revente/(?P\d+)$', + url(r'^revente/(?P\d+)/tirages$', + views.revente_tirages, + name="bda-revente-tirages"), + url(r'^revente/(?P\d+)/buy$', views.buy_revente, name="bda-buy-revente"), - url(r'^revente-interested/(?P\d+)$', + url(r'^revente/(?P\d+)/interested$', views.revente_interested, name='bda-revente-interested'), - url(r'^revente-immediat/(?P\d+)$', + url(r'^revente/(?P\d+)/immediat$', views.revente_shotgun, name="bda-shotgun"), url(r'^mails-rappel/(?P\d+)$', diff --git a/bda/views.py b/bda/views.py index 84b6c9d3..4b75c116 100644 --- a/bda/views.py +++ b/bda/views.py @@ -30,7 +30,7 @@ from bda.models import ( from bda.algorithm import Algorithm from bda.forms import ( TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, SoldForm, - InscriptionInlineFormSet, + InscriptionInlineFormSet, ReventeTirageForm, ReventeTirageAnnulForm ) @@ -377,6 +377,7 @@ def revente(request, tirage_id): if not created: revente.seller = participant revente.date = timezone.now() + revente.wanted = Participant.objects.none() revente.soldTo = None revente.notif_sent = False revente.tirage_done = False @@ -442,6 +443,53 @@ def revente(request, tirage_id): "annulform": annulform, "resellform": resellform}) +@login_required +def revente_tirages(request, tirage_id): + tirage = get_object_or_404(Tirage, id=tirage_id) + participant, _ = Participant.objects.get_or_create( + user=request.user, tirage=tirage) + unsub = 0 + subform = ReventeTirageForm(participant, prefix="subscribe") + annulform = ReventeTirageAnnulForm(participant, prefix="annul") + + if request.method == 'POST': + if "subscribe" in request.POST: + subform = ReventeTirageForm(participant, request.POST, + prefix="subscribe") + if subform.is_valid(): + sub = 0 + reventes = subform.cleaned_data['reventes'] + for revente in reventes: + revente.answered_mail.add(participant) + sub += 1 + if sub > 0: + plural = "s" if sub > 1 else "" + messages.success( + request, + "Tu as bien été inscrit à {} revente{}" + .format(sub, plural) + ) + elif "annul" in request.POST: + annulform = ReventeTirageAnnulForm(participant, request.POST, + prefix="annul") + if annulform.is_valid(): + unsub = 0 + reventes = annulform.cleaned_data['reventes'] + for revente in reventes: + revente.answered_mail.remove(participant) + unsub += 1 + if unsub > 0: + plural = "s" if unsub > 1 else "" + messages.success( + request, + "Tu as bien été désinscrit de {} revente{}" + .format(unsub, plural) + ) + + return render(request, "bda/revente-tirages.html", + {"annulform": annulform, "subform": subform}) + + @login_required def revente_interested(request, revente_id): revente = get_object_or_404(SpectacleRevente, id=revente_id) diff --git a/gestioncof/templates/home.html b/gestioncof/templates/home.html index acc04f30..f7ca57b5 100644 --- a/gestioncof/templates/home.html +++ b/gestioncof/templates/home.html @@ -43,9 +43,10 @@
  • État des demandes
  • {% else %}
  • Mes places
  • -
  • Revendre une place
  • -
  • S'inscrire à BdA-Revente
  • -
  • Places disponibles immédiatement
  • +
  • Gestion de mes reventes
  • +
  • Reventes en cours
  • +
  • S'inscrire à BdA-Revente
  • +
  • Places disponibles immédiatement
  • {% endif %} {% endfor %} From e74dbb11f1556a4ffb3cc42f83686a3a27cf5f45 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Oct 2017 18:39:45 +0200 Subject: [PATCH 04/84] Organize revente files and function names --- .../revente/confirm-shotgun.html} | 0 .../revente/confirmed.html} | 0 .../revente/mail-success.html} | 0 .../{reventes.html => revente/manage.html} | 0 .../revente/none.html} | 0 .../revente/notpaid.html} | 0 .../revente/shotgun.html} | 2 +- .../subscribe.html} | 0 .../tirages.html} | 0 .../revente/wrongtime.html} | 2 +- bda/urls.py | 30 +++++++++++-------- bda/views.py | 30 +++++++++---------- gestioncof/management/data/custommail.json | 2 +- gestioncof/templates/home.html | 6 ++-- 14 files changed, 38 insertions(+), 34 deletions(-) rename bda/templates/{revente-confirm.html => bda/revente/confirm-shotgun.html} (100%) rename bda/templates/{bda-interested.html => bda/revente/confirmed.html} (100%) rename bda/templates/{bda-success.html => bda/revente/mail-success.html} (100%) rename bda/templates/bda/{reventes.html => revente/manage.html} (100%) rename bda/templates/{bda-no-revente.html => bda/revente/none.html} (100%) rename bda/templates/{bda-notpaid.html => bda/revente/notpaid.html} (100%) rename bda/templates/{bda-shotgun.html => bda/revente/shotgun.html} (83%) rename bda/templates/bda/{liste-reventes.html => revente/subscribe.html} (100%) rename bda/templates/bda/{revente-tirages.html => revente/tirages.html} (100%) rename bda/templates/{bda-wrongtime.html => bda/revente/wrongtime.html} (86%) diff --git a/bda/templates/revente-confirm.html b/bda/templates/bda/revente/confirm-shotgun.html similarity index 100% rename from bda/templates/revente-confirm.html rename to bda/templates/bda/revente/confirm-shotgun.html diff --git a/bda/templates/bda-interested.html b/bda/templates/bda/revente/confirmed.html similarity index 100% rename from bda/templates/bda-interested.html rename to bda/templates/bda/revente/confirmed.html diff --git a/bda/templates/bda-success.html b/bda/templates/bda/revente/mail-success.html similarity index 100% rename from bda/templates/bda-success.html rename to bda/templates/bda/revente/mail-success.html diff --git a/bda/templates/bda/reventes.html b/bda/templates/bda/revente/manage.html similarity index 100% rename from bda/templates/bda/reventes.html rename to bda/templates/bda/revente/manage.html diff --git a/bda/templates/bda-no-revente.html b/bda/templates/bda/revente/none.html similarity index 100% rename from bda/templates/bda-no-revente.html rename to bda/templates/bda/revente/none.html diff --git a/bda/templates/bda-notpaid.html b/bda/templates/bda/revente/notpaid.html similarity index 100% rename from bda/templates/bda-notpaid.html rename to bda/templates/bda/revente/notpaid.html diff --git a/bda/templates/bda-shotgun.html b/bda/templates/bda/revente/shotgun.html similarity index 83% rename from bda/templates/bda-shotgun.html rename to bda/templates/bda/revente/shotgun.html index e10fae00..fae36c04 100644 --- a/bda/templates/bda-shotgun.html +++ b/bda/templates/bda/revente/shotgun.html @@ -5,7 +5,7 @@ {% if shotgun %}
      {% for spectacle in shotgun %} -
    • {{spectacle}}
    • +
    • {{spectacle}}
    • {% endfor %} {% else %}

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

      diff --git a/bda/templates/bda/liste-reventes.html b/bda/templates/bda/revente/subscribe.html similarity index 100% rename from bda/templates/bda/liste-reventes.html rename to bda/templates/bda/revente/subscribe.html diff --git a/bda/templates/bda/revente-tirages.html b/bda/templates/bda/revente/tirages.html similarity index 100% rename from bda/templates/bda/revente-tirages.html rename to bda/templates/bda/revente/tirages.html diff --git a/bda/templates/bda-wrongtime.html b/bda/templates/bda/revente/wrongtime.html similarity index 86% rename from bda/templates/bda-wrongtime.html rename to bda/templates/bda/revente/wrongtime.html index dfafb05f..18c417a2 100644 --- a/bda/templates/bda-wrongtime.html +++ b/bda/templates/bda/revente/wrongtime.html @@ -6,7 +6,7 @@

      Le tirage au sort de cette revente a déjà été effectué !

      Si personne n'était intéressé, elle est maintenant disponible - ici.

      + ici.

      {% else %}

      Il n'est pas encore possible de s'inscrire à cette revente, réessaie dans quelque temps !

      {% endif %} diff --git a/bda/urls.py b/bda/urls.py index 51dd4235..7588187c 100644 --- a/bda/urls.py +++ b/bda/urls.py @@ -16,9 +16,6 @@ urlpatterns = [ url(r'^places/(?P\d+)$', views.places, name="bda-places-attribuees"), - url(r'^revente/(?P\d+)$', - views.revente, - name='bda-revente'), url(r'^etat-places/(?P\d+)$', views.etat_places, name='bda-etat-places'), @@ -32,21 +29,28 @@ urlpatterns = [ url(r'^spectacles/unpaid/(?P\d+)$', views.unpaid, name="bda-unpaid"), - url(r'^revente/(?P\d+)/list$', - views.list_revente, - name="bda-liste-revente"), + + # Urls BdA-Revente + + url(r'^revente/(?P\d+)/manage$', + views.revente_manage, + name='bda-revente-manage'), + url(r'^revente/(?P\d+)/subscribe$', + views.revente_subscribe, + name="bda-revente-subscribe"), url(r'^revente/(?P\d+)/tirages$', views.revente_tirages, name="bda-revente-tirages"), url(r'^revente/(?P\d+)/buy$', - views.buy_revente, - name="bda-buy-revente"), - url(r'^revente/(?P\d+)/interested$', - views.revente_interested, - name='bda-revente-interested'), - url(r'^revente/(?P\d+)/immediat$', + views.revente_buy, + name="bda-revente-buy"), + url(r'^revente/(?P\d+)/confirm$', + views.revente_confirm, + name='bda-revente-confirm'), + url(r'^revente/(?P\d+)/shotgun$', views.revente_shotgun, - name="bda-shotgun"), + name="bda-revente-shotgun"), + url(r'^mails-rappel/(?P\d+)$', views.send_rappel, name="bda-rappels" diff --git a/bda/views.py b/bda/views.py index 4b75c116..c0e64230 100644 --- a/bda/views.py +++ b/bda/views.py @@ -349,13 +349,13 @@ def tirage(request, tirage_id): @login_required -def revente(request, tirage_id): +def revente_manage(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) participant, created = Participant.objects.get_or_create( user=request.user, tirage=tirage) if not participant.paid: - return render(request, "bda-notpaid.html", {}) + return render(request, "bda/revente/notpaid.html", {}) resellform = ResellForm(participant, prefix='resell') annulform = AnnulForm(participant, prefix='annul') @@ -438,7 +438,7 @@ def revente(request, tirage_id): .filter( Q(revente__soldTo__isnull=True) | Q(revente__soldTo=participant)) - return render(request, "bda/reventes.html", + return render(request, "bda/revente/manage.html", {'tirage': tirage, 'overdue': overdue, "soldform": soldform, "annulform": annulform, "resellform": resellform}) @@ -486,27 +486,27 @@ def revente_tirages(request, tirage_id): .format(unsub, plural) ) - return render(request, "bda/revente-tirages.html", + return render(request, "bda/revente/tirages.html", {"annulform": annulform, "subform": subform}) @login_required -def revente_interested(request, revente_id): +def revente_confirm(request, revente_id): revente = get_object_or_404(SpectacleRevente, id=revente_id) participant, _ = Participant.objects.get_or_create( user=request.user, tirage=revente.attribution.spectacle.tirage) if (timezone.now() < revente.date + timedelta(hours=1)) or revente.shotgun: - return render(request, "bda-wrongtime.html", + return render(request, "bda/revente/wrongtime.html", {"revente": revente}) revente.answered_mail.add(participant) - return render(request, "bda-interested.html", + return render(request, "bda/revente/confirmed.html", {"spectacle": revente.attribution.spectacle, "date": revente.date_tirage}) @login_required -def list_revente(request, tirage_id): +def revente_subscribe(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) participant, _ = Participant.objects.get_or_create( user=request.user, tirage=tirage) @@ -560,11 +560,11 @@ def list_revente(request, tirage_id): ) messages.info(request, msg, extra_tags="safe") - return render(request, "bda/liste-reventes.html", {"form": form}) + return render(request, "bda/revente/subscribe.html", {"form": form}) @login_required -def buy_revente(request, spectacle_id): +def revente_buy(request, spectacle_id): spectacle = get_object_or_404(Spectacle, id=spectacle_id) tirage = spectacle.tirage participant, _ = Participant.objects.get_or_create( @@ -578,13 +578,13 @@ def buy_revente(request, spectacle_id): own_reventes = reventes.filter(seller=participant) if len(own_reventes) > 0: own_reventes[0].delete() - return HttpResponseRedirect(reverse("bda-shotgun", + return HttpResponseRedirect(reverse("bda-revente-shotgun", args=[tirage.id])) reventes_shotgun = reventes.filter(shotgun=True) if not reventes_shotgun: - return render(request, "bda-no-revente.html", {}) + return render(request, "bda/revente/none.html", {}) if request.POST: revente = random.choice(reventes_shotgun) @@ -601,11 +601,11 @@ def buy_revente(request, spectacle_id): [revente.seller.user.email], context=context, ) - return render(request, "bda-success.html", + return render(request, "bda/revente/mail-success.html", {"seller": revente.attribution.participant.user, "spectacle": spectacle}) - return render(request, "revente-confirm.html", + return render(request, "bda/revente/confirm-shotgun.html", {"spectacle": spectacle, "user": request.user}) @@ -629,7 +629,7 @@ def revente_shotgun(request, tirage_id): ) shotgun = [sp for sp in spectacles if len(sp.shotguns) > 0] - return render(request, "bda-shotgun.html", + return render(request, "bda/revente/shotgun.html", {"shotgun": shotgun}) diff --git a/gestioncof/management/data/custommail.json b/gestioncof/management/data/custommail.json index 9ee9b1ea..bf59e5f6 100644 --- a/gestioncof/management/data/custommail.json +++ b/gestioncof/management/data/custommail.json @@ -151,7 +151,7 @@ "shortname": "bda-revente", "subject": "{{ show }}", "description": "Notification envoy\u00e9e \u00e0 toutes les personnes int\u00e9ress\u00e9es par un spectacle pour le signaler qu'une place vient d'\u00eatre mise en vente.", - "body": "Bonjour {{ member.first_name }}\r\n\r\nUne place pour le spectacle {{ show.title }} ({{ show.date }})\r\na \u00e9t\u00e9 post\u00e9e sur BdA-Revente.\r\n\r\nSi ce spectacle t'int\u00e9resse toujours, merci de nous le signaler en cliquant\r\nsur ce lien : http://{{ site }}{% url \"bda-revente-interested\" revente.id %}.\r\nDans le cas o\u00f9 plusieurs personnes seraient int\u00e9ress\u00e9es, nous proc\u00e8derons \u00e0\r\nun tirage au sort le {{ revente.date_tirage|date:\"DATE_FORMAT\" }}.\r\n\r\nChaleureusement,\r\nLe BdA" + "body": "Bonjour {{ member.first_name }}\r\n\r\nUne place pour le spectacle {{ show.title }} ({{ show.date }})\r\na \u00e9t\u00e9 post\u00e9e sur BdA-Revente.\r\n\r\nSi ce spectacle t'int\u00e9resse toujours, merci de nous le signaler en cliquant\r\nsur ce lien : http://{{ site }}{% url \"bda-revente-confirm\" revente.id %}.\r\nDans le cas o\u00f9 plusieurs personnes seraient int\u00e9ress\u00e9es, nous proc\u00e8derons \u00e0\r\nun tirage au sort le {{ revente.date_tirage|date:\"DATE_FORMAT\" }}.\r\n\r\nChaleureusement,\r\nLe BdA" } }, { diff --git a/gestioncof/templates/home.html b/gestioncof/templates/home.html index f7ca57b5..943ef780 100644 --- a/gestioncof/templates/home.html +++ b/gestioncof/templates/home.html @@ -43,10 +43,10 @@
    • État des demandes
    • {% else %}
    • Mes places
    • -
    • Gestion de mes reventes
    • +
    • Gestion de mes reventes
    • Reventes en cours
    • -
    • S'inscrire à BdA-Revente
    • -
    • Places disponibles immédiatement
    • +
    • S'inscrire à BdA-Revente
    • +
    • Places disponibles immédiatement
    • {% endif %}
    {% endfor %} From 919bcd197d077767bdc07355a70a84d39f8ebecf Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Oct 2017 18:59:30 +0200 Subject: [PATCH 05/84] Small code QoL improvements --- bda/models.py | 10 ++++++++++ bda/views.py | 10 ++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/bda/models.py b/bda/models.py index 41462d70..5533e3bb 100644 --- a/bda/models.py +++ b/bda/models.py @@ -252,6 +252,16 @@ class SpectacleRevente(models.Model): class Meta: verbose_name = "Revente" + def reset(self): + """Réinitialise la revente pour permettre une remise sur le marché""" + self.seller = self.attribution.participant + self.date = timezone.now() + self.answered_mail.clear() + self.soldTo = None + self.notif_sent = False + self.tirage_done = False + self.shotgun = False + def send_notif(self): """ Envoie une notification pour indiquer la mise en vente d'une place sur diff --git a/bda/views.py b/bda/views.py index c0e64230..311d530a 100644 --- a/bda/views.py +++ b/bda/views.py @@ -375,13 +375,7 @@ def revente_manage(request, tirage_id): attribution=attribution, defaults={'seller': participant}) if not created: - revente.seller = participant - revente.date = timezone.now() - revente.wanted = Participant.objects.none() - revente.soldTo = None - revente.notif_sent = False - revente.tirage_done = False - revente.shotgun = False + revente.reset() context = { 'vendeur': participant.user, 'show': attribution.spectacle, @@ -495,7 +489,7 @@ def revente_confirm(request, revente_id): revente = get_object_or_404(SpectacleRevente, id=revente_id) participant, _ = Participant.objects.get_or_create( user=request.user, tirage=revente.attribution.spectacle.tirage) - if (timezone.now() < revente.date + timedelta(hours=1)) or revente.shotgun: + if not revente.notif_sent or revente.shotgun: return render(request, "bda/revente/wrongtime.html", {"revente": revente}) From 1b0e4285ecbc7ea224cb7fad9c725365d4a9ba01 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Oct 2017 20:26:07 +0200 Subject: [PATCH 06/84] Reverse match fix --- gestioncof/management/data/custommail.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gestioncof/management/data/custommail.json b/gestioncof/management/data/custommail.json index bf59e5f6..029c03e0 100644 --- a/gestioncof/management/data/custommail.json +++ b/gestioncof/management/data/custommail.json @@ -161,7 +161,7 @@ "shortname": "bda-shotgun", "subject": "{{ show }}", "description": "Notification signalant qu'une place est au shotgun aux personnes int\u00e9ress\u00e9es.", - "body": "Bonjour {{ member.first_name }}\r\n\r\nUne place pour le spectacle {{ show.title }} ({{ show.date }})\r\na \u00e9t\u00e9 post\u00e9e sur BdA-Revente.\r\n\r\nPuisque ce spectacle a lieu dans moins de 24h, il n'y a pas de tirage au sort pour\r\ncette place : elle est disponible imm\u00e9diatement \u00e0 l'adresse\r\nhttp://{{ site }}{% url \"bda-buy-revente\" show.id %}, \u00e0 la disposition de tous.\r\n\r\nChaleureusement,\r\nLe BdA" + "body": "Bonjour {{ member.first_name }}\r\n\r\nUne place pour le spectacle {{ show.title }} ({{ show.date }})\r\na \u00e9t\u00e9 post\u00e9e sur BdA-Revente.\r\n\r\nPuisque ce spectacle a lieu dans moins de 24h, il n'y a pas de tirage au sort pour\r\ncette place : elle est disponible imm\u00e9diatement \u00e0 l'adresse\r\nhttp://{{ site }}{% url \"bda-revente-buy\" show.id %}, \u00e0 la disposition de tous.\r\n\r\nChaleureusement,\r\nLe BdA" } }, { From 684603709e90e471f5528ab35f1efc2011145fb5 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Oct 2017 20:30:11 +0200 Subject: [PATCH 07/84] Class attributes and properties + more verbose log SpectacleRevente gets brand new properties and attributes to simplify code ; also, manage_reventes command output is more verbose --- bda/management/commands/manage_reventes.py | 48 ++++++++++++++-------- bda/models.py | 45 +++++++++++++++++--- 2 files changed, 69 insertions(+), 24 deletions(-) diff --git a/bda/management/commands/manage_reventes.py b/bda/management/commands/manage_reventes.py index 0302ec4b..5a767604 100644 --- a/bda/management/commands/manage_reventes.py +++ b/bda/management/commands/manage_reventes.py @@ -6,7 +6,6 @@ Gestion en ligne de commande des reventes. from __future__ import unicode_literals -from datetime import timedelta from django.core.management import BaseCommand from django.utils import timezone from bda.models import SpectacleRevente @@ -21,23 +20,36 @@ class Command(BaseCommand): now = timezone.now() reventes = SpectacleRevente.objects.all() for revente in reventes: - # Check si < 24h - if (revente.attribution.spectacle.date <= - revente.date + timedelta(days=1)) and \ - now >= revente.date + timedelta(minutes=15) and \ - not revente.notif_sent: - self.stdout.write(str(now)) - revente.mail_shotgun() - self.stdout.write("Mail de disponibilité immédiate envoyé") - # Check si délai de retrait dépassé - elif (now >= revente.date + timedelta(hours=1) and - not revente.notif_sent): + # Le spectacle est bientôt et on a pas encore envoyé de mail : + # on met la place au shotgun et on prévient. + if revente.is_urgent and not revente.notif_sent: + if revente.can_notif: + self.stdout.write(str(now)) + revente.mail_shotgun() + self.stdout.write( + "Mails de disponibilité immédiate envoyés " + "pour la revente [%s]" % revente + ) + + # Le spectacle est dans plus longtemps : on prévient + elif (revente.can_notif and not revente.notif_sent): self.stdout.write(str(now)) revente.send_notif() - self.stdout.write("Mail d'inscription à une revente envoyé") - # Check si tirage à faire - elif (now >= revente.date_tirage and - not revente.tirage_done): + self.stdout.write( + "Mails d'inscription à la revente [%s] envoyés" + % revente + ) + + # On fait le tirage + elif (now >= revente.date_tirage and not revente.tirage_done): self.stdout.write(str(now)) - revente.tirage() - self.stdout.write("Tirage effectué, mails envoyés") + winner = revente.tirage() + self.stdout.write( + "Tirage effectué pour la revente [%s]" + % revente + ) + + if winner: + self.stdout.write("Gagnant : %s" % winner.user) + else: + self.stdout.write("Pas de gagnant ; place au shotgun") diff --git a/bda/models.py b/bda/models.py index 5533e3bb..b2882900 100644 --- a/bda/models.py +++ b/bda/models.py @@ -233,17 +233,46 @@ class SpectacleRevente(models.Model): default=False) shotgun = models.BooleanField("Disponible immédiatement", default=False) + #### + # Some class attributes + ### + # TODO : settings ? + + # Temps minimum entre le tirage et le spectacle + min_margin = timedelta(days=5) + + # Temps entre la création d'une revente et l'envoi du mail + remorse_time = timedelta(hours=1) + + # Temps min/max d'attente avant le tirage + max_wait_time = timedelta(days=3) + min_wait_time = timedelta(days=1) @property def date_tirage(self): """Renvoie la date du tirage au sort de la revente.""" - # L'acheteur doit être connu au plus 12h avant le spectacle + notif_time = self.date + self.remorse_time + remaining_time = (self.attribution.spectacle.date - - self.date - timedelta(hours=13)) - # Au minimum, on attend 2 jours avant le tirage - delay = min(remaining_time, timedelta(days=2)) - # Le vendeur a aussi 1h pour changer d'avis - return self.date + delay + timedelta(hours=1) + - notif_time - self.min_margin) + + delay = min(remaining_time, self.max_wait_time) + + return notif_time + delay + + @property + def is_urgent(self): + """ + Renvoie True iff la revente doit être mise au shotgun directement. + Plus précisément, on doit avoir min_margin + min_wait_time de marge. + """ + spectacle_date = self.attribution.spectacle.date + return (spectacle_date <= timezone.now() + self.min_margin + + self.min_wait_time) + + @property + def can_notif(self): + return (timezone.now() >= self.date + self.remorse_time) def __str__(self): return "%s -- %s" % (self.seller, @@ -353,8 +382,12 @@ class SpectacleRevente(models.Model): [inscrit.user.email] )) send_mass_custom_mail(datatuple) + + return winner + # Si personne ne veut de la place, elle part au shotgun else: self.shotgun = True + return None self.tirage_done = True self.save() From 6a6549e0d72937d9f5adaf7bf43ff090150b4891 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Oct 2017 20:52:25 +0200 Subject: [PATCH 08/84] Add notif time In case of a gestioCOF bug, we keep the notification time in memory to still do the drawing 1-3 days after. --- bda/admin.py | 6 +++--- bda/forms.py | 4 ++-- bda/migrations/0012_notif_time.py | 28 ++++++++++++++++++++++++++++ bda/models.py | 29 +++++++++++++++++++++-------- bda/views.py | 14 +++++++------- 5 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 bda/migrations/0012_notif_time.py diff --git a/bda/admin.py b/bda/admin.py index 60d3c1ba..4f5d821a 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -225,7 +225,7 @@ class SpectacleReventeAdminForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['answered_mail'].queryset = ( + self.fields['confirmed_entry'].queryset = ( Participant.objects .select_related('user', 'tirage') ) @@ -292,8 +292,8 @@ class SpectacleReventeAdmin(admin.ModelAdmin): revente.soldTo = None revente.notif_sent = False revente.tirage_done = False - if revente.answered_mail: - revente.answered_mail.clear() + if revente.confirmed_entry: + revente.confirmed_entry.clear() revente.save() self.message_user( request, diff --git a/bda/forms.py b/bda/forms.py index 139ef45d..11d05b0e 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -110,7 +110,7 @@ class ReventeTirageAnnulForm(forms.Form): def __init__(self, participant, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['reventes'].queryset = ( - participant.wanted.filter(soldTo__isnull=True) + participant.entered.filter(soldTo__isnull=True) .select_related('attribution__spectacle') ) @@ -130,7 +130,7 @@ class ReventeTirageForm(forms.Form): notif_sent=True, shotgun=False, tirage_done=False - ).exclude(answered_mail=participant) + ).exclude(confirmed_entry=participant) .select_related('attribution__spectacle') ) diff --git a/bda/migrations/0012_notif_time.py b/bda/migrations/0012_notif_time.py new file mode 100644 index 00000000..be66efd1 --- /dev/null +++ b/bda/migrations/0012_notif_time.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bda', '0011_tirage_appear_catalogue'), + ] + + operations = [ + migrations.RemoveField( + model_name='spectaclerevente', + name='answered_mail', + ), + migrations.AddField( + model_name='spectaclerevente', + name='confirmed_entry', + field=models.ManyToManyField(blank=True, related_name='entered', to='bda.Participant'), + ), + migrations.AddField( + model_name='spectaclerevente', + name='notif_time', + field=models.DateTimeField(blank=True, verbose_name="Moment d'envoi de la notification", null=True), + ), + ] diff --git a/bda/models.py b/bda/models.py index b2882900..2ad47dbf 100644 --- a/bda/models.py +++ b/bda/models.py @@ -218,9 +218,9 @@ class SpectacleRevente(models.Model): related_name="revente") date = models.DateTimeField("Date de mise en vente", default=timezone.now) - answered_mail = models.ManyToManyField(Participant, - related_name="wanted", - blank=True) + confirmed_entry = models.ManyToManyField(Participant, + related_name="entered", + blank=True) seller = models.ForeignKey(Participant, related_name="original_shows", verbose_name="Vendeur") @@ -229,8 +229,13 @@ class SpectacleRevente(models.Model): notif_sent = models.BooleanField("Notification envoyée", default=False) + + notif_time = models.DateTimeField("Moment d'envoi de la notification", + blank=True, null=True) + tirage_done = models.BooleanField("Tirage effectué", default=False) + shotgun = models.BooleanField("Disponible immédiatement", default=False) #### @@ -248,17 +253,23 @@ class SpectacleRevente(models.Model): max_wait_time = timedelta(days=3) min_wait_time = timedelta(days=1) + @property + def real_notif_time(self): + if self.notif_time: + return self.notif_time + else: + return self.date + self.remorse_time + @property def date_tirage(self): """Renvoie la date du tirage au sort de la revente.""" - notif_time = self.date + self.remorse_time remaining_time = (self.attribution.spectacle.date - - notif_time - self.min_margin) + - self.real_notif_time - self.min_margin) delay = min(remaining_time, self.max_wait_time) - return notif_time + delay + return self.real_notif_time + delay @property def is_urgent(self): @@ -285,7 +296,7 @@ class SpectacleRevente(models.Model): """Réinitialise la revente pour permettre une remise sur le marché""" self.seller = self.attribution.participant self.date = timezone.now() - self.answered_mail.clear() + self.confirmed_entry.clear() self.soldTo = None self.notif_sent = False self.tirage_done = False @@ -311,6 +322,7 @@ class SpectacleRevente(models.Model): ] send_mass_custom_mail(datatuple) self.notif_sent = True + self.notif_time = timezone.now() self.save() def mail_shotgun(self): @@ -332,6 +344,7 @@ class SpectacleRevente(models.Model): ] send_mass_custom_mail(datatuple) self.notif_sent = True + self.notif_time = timezone.now() # Flag inutile, sauf si l'horloge interne merde self.tirage_done = True self.shotgun = True @@ -343,7 +356,7 @@ class SpectacleRevente(models.Model): parmis les personnes intéressées par le spectacle. Les personnes sont ensuites prévenues par mail du résultat du tirage. """ - inscrits = list(self.answered_mail.all()) + inscrits = list(self.confirmed_entry.all()) spectacle = self.attribution.spectacle seller = self.seller diff --git a/bda/views.py b/bda/views.py index 311d530a..6ed22b21 100644 --- a/bda/views.py +++ b/bda/views.py @@ -420,8 +420,8 @@ def revente_manage(request, tirage_id): revente.notif_sent = False revente.tirage_done = False revente.shotgun = False - if revente.answered_mail: - revente.answered_mail.clear() + if revente.confirmed_entry: + revente.confirmed_entry.clear() revente.save() overdue = participant.attribution_set.filter( @@ -454,7 +454,7 @@ def revente_tirages(request, tirage_id): sub = 0 reventes = subform.cleaned_data['reventes'] for revente in reventes: - revente.answered_mail.add(participant) + revente.confirmed_entry.add(participant) sub += 1 if sub > 0: plural = "s" if sub > 1 else "" @@ -470,7 +470,7 @@ def revente_tirages(request, tirage_id): unsub = 0 reventes = annulform.cleaned_data['reventes'] for revente in reventes: - revente.answered_mail.remove(participant) + revente.confirmed_entry.remove(participant) unsub += 1 if unsub > 0: plural = "s" if unsub > 1 else "" @@ -493,7 +493,7 @@ def revente_confirm(request, revente_id): return render(request, "bda/revente/wrongtime.html", {"revente": revente}) - revente.answered_mail.add(participant) + revente.confirmed_entry.add(participant) return render(request, "bda/revente/confirmed.html", {"spectacle": revente.attribution.spectacle, "date": revente.date_tirage}) @@ -526,12 +526,12 @@ def revente_subscribe(request, tirage_id): # la revente ayant le moins d'inscrits min_resell = ( qset.filter(shotgun=False) - .annotate(nb_subscribers=Count('answered_mail')) + .annotate(nb_subscribers=Count('confirmed_entry')) .order_by('nb_subscribers') .first() ) if min_resell is not None: - min_resell.answered_mail.add(participant) + min_resell.confirmed_entry.add(participant) inscrit_revente.append(spectacle) success = True else: From 785555c05cc874dfc2a9542608c0e94baffccc2e Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Thu, 26 Oct 2017 12:40:11 +0200 Subject: [PATCH 09/84] Misc fixes --- bda/forms.py | 10 ++++++++-- bda/migrations/0012_notif_time.py | 7 ++++--- bda/models.py | 11 ++++++----- bda/views.py | 33 +++++++++++-------------------- 4 files changed, 30 insertions(+), 31 deletions(-) diff --git a/bda/forms.py b/bda/forms.py index 11d05b0e..2929f771 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -43,11 +43,13 @@ class TokenForm(forms.Form): class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, obj): - return "%s" % str(obj.spectacle) + return str(obj.spectacle) + class ReventeModelMultipleChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, obj): - return "%s" % str(obj.attribution.spectacle) + return str(obj.attribution.spectacle) + class ResellForm(forms.Form): attributions = AttributionModelMultipleChoiceField( @@ -66,6 +68,7 @@ class ResellForm(forms.Form): 'participant__user') ) + class AnnulForm(forms.Form): attributions = AttributionModelMultipleChoiceField( label='', @@ -85,6 +88,7 @@ class AnnulForm(forms.Form): 'participant__user') ) + class InscriptionReventeForm(forms.Form): spectacles = forms.ModelMultipleChoiceField( queryset=Spectacle.objects.none(), @@ -99,6 +103,7 @@ class InscriptionReventeForm(forms.Form): .filter(date__gte=timezone.now()) ) + class ReventeTirageAnnulForm(forms.Form): reventes = ReventeModelMultipleChoiceField( label='', @@ -134,6 +139,7 @@ class ReventeTirageForm(forms.Form): .select_related('attribution__spectacle') ) + class SoldForm(forms.Form): attributions = AttributionModelMultipleChoiceField( label='', diff --git a/bda/migrations/0012_notif_time.py b/bda/migrations/0012_notif_time.py index be66efd1..ee777e35 100644 --- a/bda/migrations/0012_notif_time.py +++ b/bda/migrations/0012_notif_time.py @@ -11,11 +11,12 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RemoveField( + migrations.RenameField( model_name='spectaclerevente', - name='answered_mail', + old_name='answered_mail', + new_name='confirmed_entry', ), - migrations.AddField( + migrations.AlterField( model_name='spectaclerevente', name='confirmed_entry', field=models.ManyToManyField(blank=True, related_name='entered', to='bda.Participant'), diff --git a/bda/models.py b/bda/models.py index 2ad47dbf..59827621 100644 --- a/bda/models.py +++ b/bda/models.py @@ -168,6 +168,7 @@ class Participant(models.Model): def __str__(self): return "%s - %s" % (self.user, self.tirage.title) + DOUBLE_CHOICES = ( ("1", "1 place"), ("autoquit", "2 places si possible, 1 sinon"), @@ -292,15 +293,16 @@ class SpectacleRevente(models.Model): class Meta: verbose_name = "Revente" - def reset(self): + def reset(self, new_date=timezone.now()): """Réinitialise la revente pour permettre une remise sur le marché""" self.seller = self.attribution.participant - self.date = timezone.now() + self.date = new_date self.confirmed_entry.clear() self.soldTo = None self.notif_sent = False self.tirage_done = False self.shotgun = False + self.save() def send_notif(self): """ @@ -396,11 +398,10 @@ class SpectacleRevente(models.Model): )) send_mass_custom_mail(datatuple) - return winner - # Si personne ne veut de la place, elle part au shotgun else: + winner = None self.shotgun = True - return None self.tirage_done = True self.save() + return winner diff --git a/bda/views.py b/bda/views.py index 6ed22b21..fb1a2e82 100644 --- a/bda/views.py +++ b/bda/views.py @@ -5,7 +5,6 @@ import random import hashlib import time import json -from datetime import timedelta from custommail.shortcuts import send_mass_custom_mail, send_custom_mail from custommail.models import CustomMail from django.shortcuts import render, get_object_or_404 @@ -14,6 +13,7 @@ from django.contrib import messages from django.db import transaction from django.core import serializers from django.db.models import Count, Q, Prefetch +from django.template.defaultfilters import pluralize from django.forms.models import inlineformset_factory from django.http import ( HttpResponseBadRequest, HttpResponseRedirect, JsonResponse @@ -376,6 +376,7 @@ def revente_manage(request, tirage_id): defaults={'seller': participant}) if not created: revente.reset() + context = { 'vendeur': participant.user, 'show': attribution.spectacle, @@ -414,15 +415,10 @@ def revente_manage(request, tirage_id): attributions = soldform.cleaned_data['attributions'] for attribution in attributions: if attribution.spectacle.date > timezone.now(): - revente = attribution.revente - revente.date = timezone.now() - timedelta(minutes=65) - revente.soldTo = None - revente.notif_sent = False - revente.tirage_done = False - revente.shotgun = False - if revente.confirmed_entry: - revente.confirmed_entry.clear() - revente.save() + # On antidate pour envoyer le mail plus vite + new_date = (timezone.now() + - SpectacleRevente.remorse_time) + revente.reset(new_date=new_date) overdue = participant.attribution_set.filter( spectacle__date__gte=timezone.now(), @@ -442,7 +438,6 @@ def revente_tirages(request, tirage_id): tirage = get_object_or_404(Tirage, id=tirage_id) participant, _ = Participant.objects.get_or_create( user=request.user, tirage=tirage) - unsub = 0 subform = ReventeTirageForm(participant, prefix="subscribe") annulform = ReventeTirageAnnulForm(participant, prefix="annul") @@ -451,33 +446,29 @@ def revente_tirages(request, tirage_id): subform = ReventeTirageForm(participant, request.POST, prefix="subscribe") if subform.is_valid(): - sub = 0 reventes = subform.cleaned_data['reventes'] + count = reventes.count() for revente in reventes: revente.confirmed_entry.add(participant) - sub += 1 - if sub > 0: - plural = "s" if sub > 1 else "" + if count > 0: messages.success( request, "Tu as bien été inscrit à {} revente{}" - .format(sub, plural) + .format(count, pluralize(count)) ) elif "annul" in request.POST: annulform = ReventeTirageAnnulForm(participant, request.POST, prefix="annul") if annulform.is_valid(): - unsub = 0 reventes = annulform.cleaned_data['reventes'] + count = reventes.count() for revente in reventes: revente.confirmed_entry.remove(participant) - unsub += 1 - if unsub > 0: - plural = "s" if unsub > 1 else "" + if count > 0: messages.success( request, "Tu as bien été désinscrit de {} revente{}" - .format(unsub, plural) + .format(count, pluralize(count)) ) return render(request, "bda/revente/tirages.html", From f18959c0a1d643fab3b08921a004f158d4ba4720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Wed, 1 Nov 2017 17:26:40 +0100 Subject: [PATCH 10/84] BdA-Revente: meaningful names, some help tests --- bda/templates/bda/revente/manage.html | 58 +++++++++++++++++++----- bda/templates/bda/revente/subscribe.html | 39 ++++++++++------ bda/templates/bda/revente/tirages.html | 40 ++++++++++++---- bda/views.py | 18 ++++++++ gestioncof/static/css/cof.css | 11 +++++ gestioncof/templates/home.html | 6 +-- 6 files changed, 136 insertions(+), 36 deletions(-) diff --git a/bda/templates/bda/revente/manage.html b/bda/templates/bda/revente/manage.html index 0912babb..8162d55d 100644 --- a/bda/templates/bda/revente/manage.html +++ b/bda/templates/bda/revente/manage.html @@ -3,50 +3,84 @@ {% block realcontent %} -

    Revente de place

    +

    Gestion des places que je revends

    {% with resell_attributions=resellform.attributions annul_attributions=annulform.attributions sold_attributions=soldform.attributions %} {% if resellform.attributions %} +
    +

    Places non revendues

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

    Places en cours de revente

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

    Places revendues

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

    Plus de reventes possibles !

    diff --git a/bda/templates/bda/revente/subscribe.html b/bda/templates/bda/revente/subscribe.html index fcf57345..9a193908 100644 --- a/bda/templates/bda/revente/subscribe.html +++ b/bda/templates/bda/revente/subscribe.html @@ -4,28 +4,41 @@ {% block realcontent %}

    Inscriptions pour BdA-Revente

    +
    + + Cochez les spectacles pour lesquels vous souhaitez recevoir un + 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 + inscrit à ce tirage. +
    +
    {% csrf_token %}
    -

    Spectacles

    -
    - - + + -
    -
      - {% for checkbox in form.spectacles %} -
    • {{checkbox}}
    • - {%endfor%} -
    -
    +
    +
      + {% for checkbox in form.spectacles %} +
    • {{ checkbox }}
    • + {% endfor %} +
    +
    - +
    + diff --git a/kfet/templates/kfet/category.html b/kfet/templates/kfet/category.html index 0ea96c8f..a31cc3cf 100644 --- a/kfet/templates/kfet/category.html +++ b/kfet/templates/kfet/category.html @@ -17,12 +17,15 @@ {% block main %}
    - +
    - + diff --git a/kfet/templates/kfet/checkout.html b/kfet/templates/kfet/checkout.html index 96373c49..c2c5e4bc 100644 --- a/kfet/templates/kfet/checkout.html +++ b/kfet/templates/kfet/checkout.html @@ -24,13 +24,16 @@ {% block main %}
    -
    Nom Nombre d'articlesPeut être majoréePeut être majorée
    +
    - - + + @@ -43,8 +46,12 @@ - - + + {% endfor %} diff --git a/kfet/templates/kfet/checkout_read.html b/kfet/templates/kfet/checkout_read.html index 12ba7e64..37a6e173 100644 --- a/kfet/templates/kfet/checkout_read.html +++ b/kfet/templates/kfet/checkout_read.html @@ -14,10 +14,13 @@ {% if not statements %} Pas de relevé {% else %} -
    Nom BalanceDéb. valid.Fin valid.Déb. valid.Fin valid. Protégée
    {{ checkout.balance}}€{{ checkout.valid_from }}{{ checkout.valid_to }} + {{ checkout.valid_from|date:'d/m/Y H:i' }} + + {{ checkout.valid_to|date:'d/m/Y H:i' }} + {{ checkout.is_protected|yesno }}
    +
    - + @@ -25,9 +28,9 @@ {% for statement in statements %} - diff --git a/kfet/templates/kfet/inventory.html b/kfet/templates/kfet/inventory.html index 237c45cd..f05dc32a 100644 --- a/kfet/templates/kfet/inventory.html +++ b/kfet/templates/kfet/inventory.html @@ -17,10 +17,13 @@ {% block main %}
    -
    Date/heureDate/heure Montant pris Montant laissé Erreur
    + - {{ statement.at }} + {{ statement.at|date:'d/m/Y H:i' }} {{ statement.amount_taken }}
    +
    - + @@ -28,9 +31,9 @@ {% for inventory in inventories %} - diff --git a/kfet/templates/kfet/inventory_read.html b/kfet/templates/kfet/inventory_read.html index dd5ceb2c..1edc21e0 100644 --- a/kfet/templates/kfet/inventory_read.html +++ b/kfet/templates/kfet/inventory_read.html @@ -27,7 +27,10 @@ {% block main %}
    -
    DateDate Par Nb articles
    + - {{ inventory.at }} + {{ inventory.at|date:'d/m/Y H:i' }} {{ inventory.by }}
    +
    @@ -36,25 +39,28 @@ - - {% for inventoryart in inventoryarts %} - {% ifchanged inventoryart.article.category %} - - - - {% endifchanged %} - - - - - + {% regroup inventoryarts by article.category as category_list %} + {% for category in category_list %} + + + - {% endfor %} - + + + {% for inventoryart in category.list %} + + + + + + + {% endfor %} + + {% endfor %}
    ArticleErreur
    {{ inventoryart.article.category.name }}
    - - {{ inventoryart.article.name }} - - {{ inventoryart.stock_old }}{{ inventoryart.stock_new }}{{ inventoryart.stock_error }}
    {{ category.grouper.name }}
    + + {{ inventoryart.article.name }} + + {{ inventoryart.stock_old }}{{ inventoryart.stock_new }}{{ inventoryart.stock_error }}
    diff --git a/kfet/templates/kfet/order.html b/kfet/templates/kfet/order.html index 53ef1bfb..0e4ed868 100644 --- a/kfet/templates/kfet/order.html +++ b/kfet/templates/kfet/order.html @@ -55,11 +55,14 @@

    Liste des commandes

    - +
    - - + + @@ -74,9 +77,9 @@ {% endif %} - diff --git a/kfet/templates/kfet/order_create.html b/kfet/templates/kfet/order_create.html index d95cafe3..e2e7c4cd 100644 --- a/kfet/templates/kfet/order_create.html +++ b/kfet/templates/kfet/order_create.html @@ -11,60 +11,79 @@ {% csrf_token %}
    -
    DateDate Fournisseur Inventaire
    + - {{ order.at }} + {{ order.at|date:'d/m/Y H:i' }} {{ order.supplier }}
    +
    - - - - + + + + - - - + + + {% for label in scale.get_labels %} - + {% endfor %} - - {% for form in formset %} - {% ifchanged form.category %} - - - - {% endifchanged %} - - {{ form.article }} - - {% for v_chunk in form.v_all %} - - {% endfor %} - - - - - - - + {% regroup formset by category_name as category_list %} + {% for category in category_list %} + + + - {% endfor %} - + + + {% for form in category.list %} + + {{ form.article }} + + {% for v_chunk in form.v_all %} + + {% endfor %} + + + + + + + + + {% endfor %} + + {% endfor %}
    ArticleVentes - - V. moy.
    - -
    E.T.
    - -
    Prév.
    - -
    + Ventes + + + V. moy. +
    + +
    + E.T. +
    + +
    + Prév. +
    + +
    StockBox
    - -
    Rec.
    - -
    Commande + Box +
    + +
    + Rec. +
    + +
    + Commande +
    {{ label }}{{ label }}
    {{ form.category_name }}
    {{ form.name }}{{ v_chunk }}{{ form.v_moy }}{{ form.v_et }}{{ form.v_prev }}{{ form.stock }}{{ form.box_capacity|default:"" }}{{ form.c_rec }}{{ form.quantity_ordered | add_class:"form-control" }}
    {{ category.grouper }}
    {{ form.name }}{{ v_chunk }}{{ form.v_moy }}{{ form.v_et }}{{ form.v_prev }}{{ form.stock }}{{ form.box_capacity|default:"" }}{{ form.c_rec }}{{ form.quantity_ordered|add_class:"form-control" }}
    {{ formset.management_form }} diff --git a/kfet/templates/kfet/order_read.html b/kfet/templates/kfet/order_read.html index 69b74420..9241c394 100644 --- a/kfet/templates/kfet/order_read.html +++ b/kfet/templates/kfet/order_read.html @@ -42,7 +42,10 @@

    Détails

    - +
    @@ -51,32 +54,35 @@ - - {% for orderart in orderarts %} - {% ifchanged orderart.article.category %} - - - - {% endifchanged %} - - - - - + {% regroup orderarts by article.category as category_list %} + {% for category in category_list %} + + + - {% endfor %} - + + + {% for orderart in category.list %} + + + + + + + {% endfor %} + + {% endfor %}
    ArticleReçu
    {{ orderart.article.category.name }}
    - - {{ orderart.article.name }} - - {{ orderart.quantity_ordered }} - {% if orderart.article.box_capacity %} - {# c'est une division ! #} - {% widthratio orderart.quantity_ordered orderart.article.box_capacity 1 %} - {% endif %} - - {{ orderart.quantity_received|default_if_none:'' }} -
    {{ category.grouper.name }}
    + + {{ orderart.article.name }} + + {{ orderart.quantity_ordered }} + {% if orderart.article.box_capacity %} + {# c'est une division ! #} + {% widthratio orderart.quantity_ordered orderart.article.box_capacity 1 %} + {% endif %} + + {{ orderart.quantity_received|default_if_none:'' }} +
    diff --git a/kfet/views.py b/kfet/views.py index f1dd6834..f0665ef6 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1839,7 +1839,7 @@ def order_create(request, pk): else: formset = cls_formset(initial=initial) - scale.label_fmt = "S -{rev_i}" + scale.label_fmt = "S-{rev_i}" return render(request, 'kfet/order_create.html', { 'supplier': supplier, From 212528011acd58b14dc64859b288d9b33caf5750 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Tue, 19 Dec 2017 11:40:02 +0100 Subject: [PATCH 13/84] Add some tests --- bda/models.py | 63 +++++++++++++++++------------------ bda/tests/test_revente.py | 69 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 31 deletions(-) create mode 100644 bda/tests/test_revente.py diff --git a/bda/models.py b/bda/models.py index 59827621..1c081a56 100644 --- a/bda/models.py +++ b/bda/models.py @@ -352,7 +352,7 @@ class SpectacleRevente(models.Model): self.shotgun = True self.save() - def tirage(self): + def tirage(self, send_mails=True): """ Lance le tirage au sort associé à la revente. Un gagnant est choisi parmis les personnes intéressées par le spectacle. Les personnes sont @@ -366,37 +366,38 @@ class SpectacleRevente(models.Model): # Envoie un mail au gagnant et au vendeur winner = random.choice(inscrits) self.soldTo = winner - datatuple = [] - context = { - 'acheteur': winner.user, - 'vendeur': seller.user, - 'show': spectacle, - } - datatuple.append(( - 'bda-revente-winner', - context, - settings.MAIL_DATA['revente']['FROM'], - [winner.user.email], - )) - datatuple.append(( - 'bda-revente-seller', - context, - settings.MAIL_DATA['revente']['FROM'], - [seller.user.email] - )) + if send_mails: + datatuple = [] + context = { + 'acheteur': winner.user, + 'vendeur': seller.user, + 'show': spectacle, + } + datatuple.append(( + 'bda-revente-winner', + context, + settings.MAIL_DATA['revente']['FROM'], + [winner.user.email], + )) + datatuple.append(( + 'bda-revente-seller', + context, + settings.MAIL_DATA['revente']['FROM'], + [seller.user.email] + )) - # Envoie un mail aux perdants - for inscrit in inscrits: - if inscrit != winner: - new_context = dict(context) - new_context['acheteur'] = inscrit.user - datatuple.append(( - 'bda-revente-loser', - new_context, - settings.MAIL_DATA['revente']['FROM'], - [inscrit.user.email] - )) - send_mass_custom_mail(datatuple) + # Envoie un mail aux perdants + for inscrit in inscrits: + if inscrit != winner: + new_context = dict(context) + new_context['acheteur'] = inscrit.user + datatuple.append(( + 'bda-revente-loser', + new_context, + settings.MAIL_DATA['revente']['FROM'], + [inscrit.user.email] + )) + send_mass_custom_mail(datatuple) # Si personne ne veut de la place, elle part au shotgun else: diff --git a/bda/tests/test_revente.py b/bda/tests/test_revente.py new file mode 100644 index 00000000..8ef7be19 --- /dev/null +++ b/bda/tests/test_revente.py @@ -0,0 +1,69 @@ +from django.contrib.auth.models import User +from django.test import TestCase, Client +from django.utils import timezone + +from datetime import timedelta + +from bda.models import (Tirage, Spectacle, Salle, CategorieSpectacle, + SpectacleRevente, Attribution, Participant) + + +class TestModels(TestCase): + def setUp(self): + self.tirage = Tirage.objects.create( + title="Tirage test", + appear_catalogue=True, + ouverture=timezone.now(), + fermeture=timezone.now() + ) + self.category = CategorieSpectacle.objects.create(name="Category") + self.location = Salle.objects.create(name="here") + self.spectacle_soon = Spectacle.objects.create( + title="foo", date=timezone.now()+timedelta(days=1), + location=self.location, price=0, slots=42, + tirage=self.tirage, listing=False, category=self.category + ) + self.spectacle_later = Spectacle.objects.create( + title="bar", date=timezone.now()+timedelta(days=30), + location=self.location, price=0, slots=42, + tirage=self.tirage, listing=False, category=self.category + ) + + user_buyer = User.objects.create_user( + username="bda_buyer", password="testbuyer" + ) + user_seller = User.objects.create_user( + username="bda_seller", password="testseller" + ) + self.buyer = Participant.objects.create( + user=user_buyer, tirage=self.tirage + ) + self.seller = Participant.objects.create( + user=user_seller, tirage=self.tirage + ) + + self.attr_soon = Attribution.objects.create( + participant=self.seller, spectacle=self.spectacle_soon + ) + self.attr_later = Attribution.objects.create( + participant=self.seller, spectacle=self.spectacle_later + ) + self.revente_soon = SpectacleRevente.objects.create( + seller=self.seller, + attribution=self.attr_soon + ) + self.revente_later = SpectacleRevente.objects.create( + seller=self.seller, + attribution=self.attr_later + ) + + def test_urgent(self): + self.assertTrue(self.revente_soon.is_urgent) + self.assertFalse(self.revente_later.is_urgent) + + def test_tirage(self): + self.revente_soon.confirmed_entry.add(self.buyer) + + self.assertEqual(self.revente_soon.tirage(send_mails=False), + self.buyer) + self.assertIsNone(self.revente_later.tirage(send_mails=False)) From f1bbade002663adff353ba477a6a9889580416b8 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Tue, 19 Dec 2017 12:40:50 +0100 Subject: [PATCH 14/84] Better labels for `revente` objects The label for the ReventeModelMultipleChoiceField now depends on a `own` parameter, which determines if we display the seller or the buyer's name. --- bda/forms.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/bda/forms.py b/bda/forms.py index 2929f771..14e91ce2 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -47,8 +47,29 @@ class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField): class ReventeModelMultipleChoiceField(forms.ModelMultipleChoiceField): + def __init__(self, *args, own=True, **kwargs): + super().__init__(*args, **kwargs) + self.own = own + def label_from_instance(self, obj): - return str(obj.attribution.spectacle) + label = "{show}{suffix}" + suffix = "" + if self.own: + # C'est notre propre revente : pas besoin de spécifier le vendeur + if obj.soldTo is not None: + suffix = " -- Vendue à {firstname} {lastname}".format( + firstname=obj.soldTo.user.first_name, + lastname=obj.soldTo.user.last_name, + ) + else: + # Ce n'est pas à nous : on ne voit jamais l'acheteur + suffix = " -- Vendue par {firstname} {lastname}".format( + firstname=obj.seller.user.first_name, + lastname=obj.seller.user.last_name, + ) + + return label.format(show=str(obj.attribution.spectacle), + suffix=suffix) class ResellForm(forms.Form): @@ -106,6 +127,7 @@ class InscriptionReventeForm(forms.Form): class ReventeTirageAnnulForm(forms.Form): reventes = ReventeModelMultipleChoiceField( + own=False, label='', queryset=SpectacleRevente.objects.none(), widget=forms.CheckboxSelectMultiple, @@ -116,12 +138,14 @@ class ReventeTirageAnnulForm(forms.Form): super().__init__(*args, **kwargs) self.fields['reventes'].queryset = ( participant.entered.filter(soldTo__isnull=True) - .select_related('attribution__spectacle') + .select_related('attribution__spectacle', + 'seller__user') ) class ReventeTirageForm(forms.Form): reventes = ReventeModelMultipleChoiceField( + own=False, label='', queryset=SpectacleRevente.objects.none(), widget=forms.CheckboxSelectMultiple, From 1783196a9c137d52fcfe033a97c7c09bf2cec140 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Tue, 19 Dec 2017 12:41:50 +0100 Subject: [PATCH 15/84] Management view only deals with Revente objects Except for Revente creation, every form is now handled with revente objects, to use the display option in the previous commit. --- bda/forms.py | 34 +++++++++++++------------- bda/templates/bda/revente/manage.html | 16 ++++++------ bda/templates/bda/revente/tirages.html | 4 +-- bda/views.py | 20 +++++++-------- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bda/forms.py b/bda/forms.py index 14e91ce2..90b0359f 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -91,7 +91,8 @@ class ResellForm(forms.Form): class AnnulForm(forms.Form): - attributions = AttributionModelMultipleChoiceField( + reventes = ReventeModelMultipleChoiceField( + own=True, label='', queryset=Attribution.objects.none(), widget=forms.CheckboxSelectMultiple, @@ -99,14 +100,13 @@ class AnnulForm(forms.Form): def __init__(self, participant, *args, **kwargs): super(AnnulForm, self).__init__(*args, **kwargs) - self.fields['attributions'].queryset = ( - participant.attribution_set - .filter(spectacle__date__gte=timezone.now(), - revente__isnull=False, - revente__notif_sent=False, - revente__soldTo__isnull=True) - .select_related('spectacle', 'spectacle__location', - 'participant__user') + self.fields['reventes'].queryset = ( + participant.original_shows + .filter(attribution__spectacle__date__gte=timezone.now(), + notif_sent=False, + soldTo__isnull=True) + .select_related('attribution__spectacle', + 'attribution__spectacle__location') ) @@ -165,18 +165,18 @@ class ReventeTirageForm(forms.Form): class SoldForm(forms.Form): - attributions = AttributionModelMultipleChoiceField( + reventes = ReventeModelMultipleChoiceField( + own=True, label='', queryset=Attribution.objects.none(), widget=forms.CheckboxSelectMultiple) def __init__(self, participant, *args, **kwargs): super(SoldForm, self).__init__(*args, **kwargs) - self.fields['attributions'].queryset = ( - participant.attribution_set - .filter(revente__isnull=False, - revente__soldTo__isnull=False) - .exclude(revente__soldTo=participant) - .select_related('spectacle', 'spectacle__location', - 'participant__user') + self.fields['reventes'].queryset = ( + participant.original_shows + .filter(soldTo__isnull=False) + .exclude(soldTo=participant) + .select_related('attribution__spectacle', + 'attribution__spectacle__location') ) diff --git a/bda/templates/bda/revente/manage.html b/bda/templates/bda/revente/manage.html index 8162d55d..cf0ba80e 100644 --- a/bda/templates/bda/revente/manage.html +++ b/bda/templates/bda/revente/manage.html @@ -4,7 +4,7 @@ {% block realcontent %}

    Gestion des places que je revends

    -{% with resell_attributions=resellform.attributions annul_attributions=annulform.attributions sold_attributions=soldform.attributions %} +{% with resell_attributions=resellform.attributions annul_reventes=annulform.reventes sold_reventes=soldform.reventes %} {% if resellform.attributions %}
    @@ -29,10 +29,10 @@
    {% endif %} -{% if annul_attributions or overdue %} +{% if annul_reventes or overdue %}

    Places en cours de revente

    - {% if annul_attributions %} + {% if annul_reventes %}
    Vous pouvez annuler les places mises en vente il y a moins d'une heure. @@ -42,8 +42,8 @@
      - {% for attrib in annul_attributions %} -
    • {{ attrib.tag }} {{ attrib.choice_label }}
    • + {% for revente in annul_reventes %} +
    • {{ revente.tag }} {{ revente.choice_label }}
    • {% endfor %} {% for attrib in overdue %}
    • @@ -54,7 +54,7 @@
    - {% if annul_attributions %} + {% if annul_reventes %} {% endif %} @@ -62,7 +62,7 @@
    {% endif %} -{% if sold_attributions %} +{% if sold_reventes %}

    Places revendues

    @@ -82,7 +82,7 @@ {% endif %} -{% if not resell_attributions and not annul_attributions and not overdue and not sold_attributions %} +{% if not resell_attributions and not annul_attributions and not overdue and not sold_reventes %}

    Plus de reventes possibles !

    {% endif %} diff --git a/bda/templates/bda/revente/tirages.html b/bda/templates/bda/revente/tirages.html index 91d39c90..b7017806 100644 --- a/bda/templates/bda/revente/tirages.html +++ b/bda/templates/bda/revente/tirages.html @@ -11,7 +11,7 @@
    Vous pouvez vous désinscrire des reventes suivantes tant que le tirage n'a - pas eu lieu + pas eu lieu.
    {% csrf_token %} @@ -34,7 +34,7 @@
    - Vous pouvez vous inscrire aux tirage en cours suivants + Vous pouvez vous inscrire aux tirage en cours suivants.
    {% csrf_token %} diff --git a/bda/views.py b/bda/views.py index 67fa2486..4cb35c52 100644 --- a/bda/views.py +++ b/bda/views.py @@ -401,18 +401,18 @@ def revente_manage(request, tirage_id): elif 'annul' in request.POST: annulform = AnnulForm(participant, request.POST, prefix='annul') if annulform.is_valid(): - attributions = annulform.cleaned_data["attributions"] - for attribution in attributions: - attribution.revente.delete() + reventes = annulform.cleaned_data["reventes"] + for revente in reventes: + revente.delete() # On confirme une vente en transférant la place à la personne qui a # gagné le tirage elif 'transfer' in request.POST: soldform = SoldForm(participant, request.POST, prefix='sold') if soldform.is_valid(): - attributions = soldform.cleaned_data['attributions'] - for attribution in attributions: - attribution.participant = attribution.revente.soldTo - attribution.save() + reventes = soldform.cleaned_data['reventes'] + for reventes in reventes: + revente.attribution.participant = revente.soldTo + revente.attribution.save() # On annule la revente après le tirage au sort (par exemple si # la personne qui a gagné le tirage ne se manifeste pas). La place est @@ -420,9 +420,9 @@ def revente_manage(request, tirage_id): elif 'reinit' in request.POST: soldform = SoldForm(participant, request.POST, prefix='sold') if soldform.is_valid(): - attributions = soldform.cleaned_data['attributions'] - for attribution in attributions: - if attribution.spectacle.date > timezone.now(): + reventes = soldform.cleaned_data['reventes'] + for revente in reventes: + if revente.attribution.spectacle.date > timezone.now(): # On antidate pour envoyer le mail plus vite new_date = (timezone.now() - SpectacleRevente.remorse_time) From 9a8773978c52defaf98dd7fd58babd4961273dd5 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Tue, 19 Dec 2017 12:50:20 +0100 Subject: [PATCH 16/84] Use new method in admin --- bda/admin.py | 8 +------- bda/models.py | 1 + 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/bda/admin.py b/bda/admin.py index 4f5d821a..2d289b71 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -288,13 +288,7 @@ class SpectacleReventeAdmin(admin.ModelAdmin): count = queryset.count() for revente in queryset.filter( attribution__spectacle__date__gte=timezone.now()): - revente.date = timezone.now() - timedelta(hours=1) - revente.soldTo = None - revente.notif_sent = False - revente.tirage_done = False - if revente.confirmed_entry: - revente.confirmed_entry.clear() - revente.save() + revente.reset(new_date=timezone.now() - timedelta(hours=1)) self.message_user( request, "%d attribution%s %s été réinitialisée%s avec succès." % ( diff --git a/bda/models.py b/bda/models.py index c01fe727..722a3ef7 100644 --- a/bda/models.py +++ b/bda/models.py @@ -300,6 +300,7 @@ class SpectacleRevente(models.Model): self.confirmed_entry.clear() self.soldTo = None self.notif_sent = False + self.notif_time = None self.tirage_done = False self.shotgun = False self.save() From 475f1adec57ae2206d19a5bd49effee9433d2a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sat, 6 Jan 2018 12:23:24 +0100 Subject: [PATCH 17/84] Remove "coding: utf8" line --- bda/admin.py | 2 -- bda/algorithm.py | 2 -- bda/autocomplete_light_registry.py | 2 -- bda/forms.py | 2 -- bda/management/commands/manage_reventes.py | 2 -- bda/management/commands/sendrappels.py | 2 -- bda/models.py | 2 -- bda/urls.py | 2 -- bda/views.py | 2 -- cof/locale/fr/formats.py | 2 -- cof/settings/common.py | 1 - cof/urls.py | 2 -- gestioncof/autocomplete.py | 2 -- gestioncof/autocomplete_light_registry.py | 2 -- gestioncof/csv_views.py | 2 -- gestioncof/decorators.py | 2 -- gestioncof/management/commands/syncmails.py | 1 - gestioncof/petits_cours_forms.py | 2 -- gestioncof/petits_cours_models.py | 2 -- gestioncof/petits_cours_views.py | 2 -- gestioncof/templatetags/utils.py | 2 -- gestioncof/tests.py | 1 - gestioncof/urls.py | 2 -- gestioncof/widgets.py | 2 -- kfet/apps.py | 2 -- kfet/auth/backends.py | 1 - kfet/auth/middleware.py | 1 - kfet/auth/tests.py | 1 - kfet/autocomplete.py | 2 -- kfet/config.py | 2 -- kfet/consumers.py | 2 -- kfet/context_processors.py | 2 -- kfet/decorators.py | 2 -- kfet/forms.py | 2 -- kfet/models.py | 2 -- kfet/routing.py | 2 -- kfet/statistic.py | 2 -- kfet/templatetags/kfet_tags.py | 2 -- kfet/tests/test_config.py | 2 -- kfet/tests/test_statistic.py | 2 -- kfet/urls.py | 2 -- kfet/views.py | 2 -- 42 files changed, 78 deletions(-) diff --git a/bda/admin.py b/bda/admin.py index 60d3c1ba..4736ce2d 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import autocomplete_light from datetime import timedelta from custommail.shortcuts import send_mass_custom_mail diff --git a/bda/algorithm.py b/bda/algorithm.py index 7f18ce18..7d6ab2f0 100644 --- a/bda/algorithm.py +++ b/bda/algorithm.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from __future__ import division from __future__ import print_function from __future__ import unicode_literals diff --git a/bda/autocomplete_light_registry.py b/bda/autocomplete_light_registry.py index 6c2f3ea6..774e5c2b 100644 --- a/bda/autocomplete_light_registry.py +++ b/bda/autocomplete_light_registry.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from __future__ import division from __future__ import print_function from __future__ import unicode_literals diff --git a/bda/forms.py b/bda/forms.py index c0417d1e..3b0dd5bd 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from django import forms from django.forms.models import BaseInlineFormSet from django.utils import timezone diff --git a/bda/management/commands/manage_reventes.py b/bda/management/commands/manage_reventes.py index 0302ec4b..f5dee265 100644 --- a/bda/management/commands/manage_reventes.py +++ b/bda/management/commands/manage_reventes.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ Gestion en ligne de commande des reventes. """ diff --git a/bda/management/commands/sendrappels.py b/bda/management/commands/sendrappels.py index 88cf9d5c..8fbdb31c 100644 --- a/bda/management/commands/sendrappels.py +++ b/bda/management/commands/sendrappels.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ Gestion en ligne de commande des mails de rappel. """ diff --git a/bda/models.py b/bda/models.py index 41462d70..42c3b3ef 100644 --- a/bda/models.py +++ b/bda/models.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import calendar import random from datetime import timedelta diff --git a/bda/urls.py b/bda/urls.py index 876c84ea..52e74a67 100644 --- a/bda/urls.py +++ b/bda/urls.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from __future__ import division from __future__ import print_function from __future__ import unicode_literals diff --git a/bda/views.py b/bda/views.py index 84b6c9d3..7109443a 100644 --- a/bda/views.py +++ b/bda/views.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from collections import defaultdict import random import hashlib diff --git a/cof/locale/fr/formats.py b/cof/locale/fr/formats.py index 710fa6ed..ec63d8cc 100644 --- a/cof/locale/fr/formats.py +++ b/cof/locale/fr/formats.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- - """ Formats français. """ diff --git a/cof/settings/common.py b/cof/settings/common.py index a2ea3f5e..4c573d95 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Django common settings for cof project. diff --git a/cof/urls.py b/cof/urls.py index f62d5f01..4599d332 100644 --- a/cof/urls.py +++ b/cof/urls.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ Fichier principal de configuration des urls du projet GestioCOF """ diff --git a/gestioncof/autocomplete.py b/gestioncof/autocomplete.py index 968398fd..1d60cd78 100644 --- a/gestioncof/autocomplete.py +++ b/gestioncof/autocomplete.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from ldap3 import Connection from django import shortcuts diff --git a/gestioncof/autocomplete_light_registry.py b/gestioncof/autocomplete_light_registry.py index 4c62d995..6e04022f 100644 --- a/gestioncof/autocomplete_light_registry.py +++ b/gestioncof/autocomplete_light_registry.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import autocomplete_light from django.contrib.auth.models import User diff --git a/gestioncof/csv_views.py b/gestioncof/csv_views.py index c1d82aca..a4f3c028 100644 --- a/gestioncof/csv_views.py +++ b/gestioncof/csv_views.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from __future__ import division from __future__ import print_function from __future__ import unicode_literals diff --git a/gestioncof/decorators.py b/gestioncof/decorators.py index a1263ce3..3875b77d 100644 --- a/gestioncof/decorators.py +++ b/gestioncof/decorators.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from django.contrib.auth.decorators import user_passes_test diff --git a/gestioncof/management/commands/syncmails.py b/gestioncof/management/commands/syncmails.py index 1d3dddb8..ba61dcf4 100644 --- a/gestioncof/management/commands/syncmails.py +++ b/gestioncof/management/commands/syncmails.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Import des mails de GestioCOF dans la base de donnée """ diff --git a/gestioncof/petits_cours_forms.py b/gestioncof/petits_cours_forms.py index dfb7a263..c0770afc 100644 --- a/gestioncof/petits_cours_forms.py +++ b/gestioncof/petits_cours_forms.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from captcha.fields import ReCaptchaField from django import forms diff --git a/gestioncof/petits_cours_models.py b/gestioncof/petits_cours_models.py index d9ea9668..06199a01 100644 --- a/gestioncof/petits_cours_models.py +++ b/gestioncof/petits_cours_models.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from functools import reduce from django.db import models diff --git a/gestioncof/petits_cours_views.py b/gestioncof/petits_cours_views.py index 087c9cef..5854a927 100644 --- a/gestioncof/petits_cours_views.py +++ b/gestioncof/petits_cours_views.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import json from datetime import datetime from custommail.shortcuts import render_custom_mail diff --git a/gestioncof/templatetags/utils.py b/gestioncof/templatetags/utils.py index 76bc6003..5afd8cfa 100644 --- a/gestioncof/templatetags/utils.py +++ b/gestioncof/templatetags/utils.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from __future__ import division from __future__ import print_function from __future__ import unicode_literals diff --git a/gestioncof/tests.py b/gestioncof/tests.py index 66043daf..f99b0fcb 100644 --- a/gestioncof/tests.py +++ b/gestioncof/tests.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This file demonstrates writing tests using the unittest module. These will pass when you run "manage.py test". diff --git a/gestioncof/urls.py b/gestioncof/urls.py index 57c2e8f2..02814673 100644 --- a/gestioncof/urls.py +++ b/gestioncof/urls.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from django.conf.urls import url from gestioncof.petits_cours_views import DemandeListView, DemandeDetailView from gestioncof import views, petits_cours_views diff --git a/gestioncof/widgets.py b/gestioncof/widgets.py index 758fc4ad..a44e93b0 100644 --- a/gestioncof/widgets.py +++ b/gestioncof/widgets.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from __future__ import division from __future__ import print_function from __future__ import unicode_literals diff --git a/kfet/apps.py b/kfet/apps.py index 4f114c37..a18dd905 100644 --- a/kfet/apps.py +++ b/kfet/apps.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from __future__ import (absolute_import, division, print_function, unicode_literals) from builtins import * diff --git a/kfet/auth/backends.py b/kfet/auth/backends.py index c6ad21b2..d8ef3001 100644 --- a/kfet/auth/backends.py +++ b/kfet/auth/backends.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from django.contrib.auth import get_user_model from kfet.models import Account, GenericTeamToken diff --git a/kfet/auth/middleware.py b/kfet/auth/middleware.py index 748ce4dd..388be4fc 100644 --- a/kfet/auth/middleware.py +++ b/kfet/auth/middleware.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from django.contrib.auth import get_user_model from .backends import AccountBackend diff --git a/kfet/auth/tests.py b/kfet/auth/tests.py index c2f183cd..3a61daa2 100644 --- a/kfet/auth/tests.py +++ b/kfet/auth/tests.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from unittest import mock from django.core import signing diff --git a/kfet/autocomplete.py b/kfet/autocomplete.py index c4886180..0d1904d6 100644 --- a/kfet/autocomplete.py +++ b/kfet/autocomplete.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from ldap3 import Connection from django.shortcuts import render from django.http import Http404 diff --git a/kfet/config.py b/kfet/config.py index 76da5a79..f248b370 100644 --- a/kfet/config.py +++ b/kfet/config.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from django.core.exceptions import ValidationError from django.db import models diff --git a/kfet/consumers.py b/kfet/consumers.py index 0f447d2d..a53bbb72 100644 --- a/kfet/consumers.py +++ b/kfet/consumers.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from .utils import DjangoJsonWebsocketConsumer, PermConsumerMixin diff --git a/kfet/context_processors.py b/kfet/context_processors.py index 04feec81..89678f62 100644 --- a/kfet/context_processors.py +++ b/kfet/context_processors.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from kfet.config import kfet_config diff --git a/kfet/decorators.py b/kfet/decorators.py index 0c8a1a76..66c9d71c 100644 --- a/kfet/decorators.py +++ b/kfet/decorators.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from django.contrib.auth.decorators import user_passes_test diff --git a/kfet/forms.py b/kfet/forms.py index 963e4254..5cfef918 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from datetime import timedelta from decimal import Decimal diff --git a/kfet/models.py b/kfet/models.py index b1e351d5..08ca4490 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from django.db import models from django.core.urlresolvers import reverse from django.core.validators import RegexValidator diff --git a/kfet/routing.py b/kfet/routing.py index 54de69ae..f1305d4b 100644 --- a/kfet/routing.py +++ b/kfet/routing.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from channels.routing import include, route_class from . import consumers diff --git a/kfet/statistic.py b/kfet/statistic.py index 3f32807e..0aba4dda 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from datetime import date, datetime, time, timedelta from dateutil.relativedelta import relativedelta diff --git a/kfet/templatetags/kfet_tags.py b/kfet/templatetags/kfet_tags.py index f5cd3848..68b74738 100644 --- a/kfet/templatetags/kfet_tags.py +++ b/kfet/templatetags/kfet_tags.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import re from django import template diff --git a/kfet/tests/test_config.py b/kfet/tests/test_config.py index 03c9cf3c..43497ca8 100644 --- a/kfet/tests/test_config.py +++ b/kfet/tests/test_config.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from decimal import Decimal from django.test import TestCase diff --git a/kfet/tests/test_statistic.py b/kfet/tests/test_statistic.py index d8db7ec8..93de27a0 100644 --- a/kfet/tests/test_statistic.py +++ b/kfet/tests/test_statistic.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from unittest.mock import patch from django.test import TestCase, Client diff --git a/kfet/urls.py b/kfet/urls.py index f39299a5..96fd4ddf 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from django.conf.urls import include, url from django.contrib.auth.decorators import permission_required diff --git a/kfet/views.py b/kfet/views.py index f1dd6834..5c52637d 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import ast from urllib.parse import urlencode From 57411ab46f9da69f0f8d392f6b08785f878d98a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sat, 6 Jan 2018 12:32:42 +0100 Subject: [PATCH 18/84] Remove __future__ imports --- bda/algorithm.py | 4 ---- bda/autocomplete_light_registry.py | 4 ---- bda/management/commands/manage_reventes.py | 2 -- bda/management/commands/sendrappels.py | 2 -- bda/urls.py | 4 ---- cof/locale/fr/formats.py | 2 -- gestioncof/csv_views.py | 4 ---- gestioncof/templatetags/utils.py | 4 ---- gestioncof/tests.py | 4 ---- gestioncof/widgets.py | 4 ---- kfet/apps.py | 2 -- requirements.txt | 1 - 12 files changed, 37 deletions(-) diff --git a/bda/algorithm.py b/bda/algorithm.py index 7d6ab2f0..f0f48ad9 100644 --- a/bda/algorithm.py +++ b/bda/algorithm.py @@ -1,7 +1,3 @@ -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - from django.db.models import Max import random diff --git a/bda/autocomplete_light_registry.py b/bda/autocomplete_light_registry.py index 774e5c2b..7aa43b07 100644 --- a/bda/autocomplete_light_registry.py +++ b/bda/autocomplete_light_registry.py @@ -1,7 +1,3 @@ -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - import autocomplete_light from bda.models import Participant, Spectacle diff --git a/bda/management/commands/manage_reventes.py b/bda/management/commands/manage_reventes.py index f5dee265..23bb7ae6 100644 --- a/bda/management/commands/manage_reventes.py +++ b/bda/management/commands/manage_reventes.py @@ -2,8 +2,6 @@ Gestion en ligne de commande des reventes. """ -from __future__ import unicode_literals - from datetime import timedelta from django.core.management import BaseCommand from django.utils import timezone diff --git a/bda/management/commands/sendrappels.py b/bda/management/commands/sendrappels.py index 8fbdb31c..82889f80 100644 --- a/bda/management/commands/sendrappels.py +++ b/bda/management/commands/sendrappels.py @@ -2,8 +2,6 @@ Gestion en ligne de commande des mails de rappel. """ -from __future__ import unicode_literals - from datetime import timedelta from django.core.management.base import BaseCommand from django.utils import timezone diff --git a/bda/urls.py b/bda/urls.py index 52e74a67..8a27fed0 100644 --- a/bda/urls.py +++ b/bda/urls.py @@ -1,7 +1,3 @@ -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - from django.conf.urls import url from gestioncof.decorators import buro_required from bda.views import SpectacleListView diff --git a/cof/locale/fr/formats.py b/cof/locale/fr/formats.py index ec63d8cc..4b47ce3d 100644 --- a/cof/locale/fr/formats.py +++ b/cof/locale/fr/formats.py @@ -2,6 +2,4 @@ Formats français. """ -from __future__ import unicode_literals - DATETIME_FORMAT = r'l j F Y \à H:i' diff --git a/gestioncof/csv_views.py b/gestioncof/csv_views.py index a4f3c028..733768dc 100644 --- a/gestioncof/csv_views.py +++ b/gestioncof/csv_views.py @@ -1,7 +1,3 @@ -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - import csv from django.http import HttpResponse, HttpResponseForbidden from django.template.defaultfilters import slugify diff --git a/gestioncof/templatetags/utils.py b/gestioncof/templatetags/utils.py index 5afd8cfa..2b732aec 100644 --- a/gestioncof/templatetags/utils.py +++ b/gestioncof/templatetags/utils.py @@ -1,7 +1,3 @@ -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - from django import template from django.utils.safestring import mark_safe diff --git a/gestioncof/tests.py b/gestioncof/tests.py index f99b0fcb..85673edd 100644 --- a/gestioncof/tests.py +++ b/gestioncof/tests.py @@ -5,10 +5,6 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - from django.test import TestCase from gestioncof.models import CofProfile, User diff --git a/gestioncof/widgets.py b/gestioncof/widgets.py index a44e93b0..134ddd80 100644 --- a/gestioncof/widgets.py +++ b/gestioncof/widgets.py @@ -1,7 +1,3 @@ -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - from django.forms.widgets import Widget from django.forms.utils import flatatt from django.utils.safestring import mark_safe diff --git a/kfet/apps.py b/kfet/apps.py index a18dd905..8d8170e9 100644 --- a/kfet/apps.py +++ b/kfet/apps.py @@ -1,5 +1,3 @@ -from __future__ import (absolute_import, division, - print_function, unicode_literals) from builtins import * from django.apps import AppConfig diff --git a/requirements.txt b/requirements.txt index 1591656d..c9c6c4c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,6 @@ asgiref==1.1.1 daphne==1.3.0 asgi-redis==1.3.0 statistics==1.0.3.5 -future==0.15.2 django-widget-tweaks==1.4.1 git+https://git.eleves.ens.fr/cof-geek/django_custommail.git#egg=django_custommail ldap3 From 97eed06b6fdd1e808e5add5200a52ae8429cd8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sat, 6 Jan 2018 12:33:30 +0100 Subject: [PATCH 19/84] Remove builtins imports --- kfet/apps.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kfet/apps.py b/kfet/apps.py index 8d8170e9..7a6c97a2 100644 --- a/kfet/apps.py +++ b/kfet/apps.py @@ -1,7 +1,6 @@ -from builtins import * - from django.apps import AppConfig + class KFetConfig(AppConfig): name = 'kfet' verbose_name = "Application K-Fêt" From 62d8c2ffaf765e1af33767eb6aa8e85ad4cc019a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sat, 6 Jan 2018 12:37:00 +0100 Subject: [PATCH 20/84] remove @py2_unicode_compat + six --- kfet/models.py | 8 +++----- requirements.txt | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/kfet/models.py b/kfet/models.py index 08ca4490..4d30d719 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -1,11 +1,11 @@ +from functools import reduce + from django.db import models from django.core.urlresolvers import reverse from django.core.validators import RegexValidator from django.contrib.auth.models import User from gestioncof.models import CofProfile -from django.utils.six.moves import reduce from django.utils import timezone -from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from django.db import transaction from django.db.models import F @@ -370,7 +370,7 @@ class CheckoutTransfer(models.Model): amount = models.DecimalField( max_digits = 6, decimal_places = 2) -@python_2_unicode_compatible + class CheckoutStatement(models.Model): by = models.ForeignKey( Account, on_delete = models.PROTECT, @@ -439,7 +439,6 @@ class CheckoutStatement(models.Model): super(CheckoutStatement, self).save(*args, **kwargs) -@python_2_unicode_compatible class ArticleCategory(models.Model): name = models.CharField("nom", max_length=45) has_addcost = models.BooleanField("majorée", default=True, @@ -452,7 +451,6 @@ class ArticleCategory(models.Model): return self.name -@python_2_unicode_compatible class Article(models.Model): name = models.CharField("nom", max_length = 45) is_sold = models.BooleanField("en vente", default = True) diff --git a/requirements.txt b/requirements.txt index c9c6c4c0..914eca1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ django-recaptcha==1.0.5 django-redis-cache==1.7.1 psycopg2 Pillow==3.3.0 -six==1.10.0 unicodecsv==0.14.1 icalendar==3.10 django-bootstrap-form==3.2.1 From 91119f68bc5deef627baa47ec7fb80e56c0bb514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Wed, 10 Jan 2018 17:34:41 +0100 Subject: [PATCH 21/84] =?UTF-8?q?Ne=20pas=20oublier=20avant=20de=20passer?= =?UTF-8?q?=20en=20prod=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_PROD.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 TODO_PROD.md diff --git a/TODO_PROD.md b/TODO_PROD.md new file mode 100644 index 00000000..1a7d0736 --- /dev/null +++ b/TODO_PROD.md @@ -0,0 +1 @@ +- Changer les urls dans les mails "bda-revente" et "bda-shotgun" From 52fd49616dab6515ab6e113ccb405878c59f18dd Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Wed, 10 Jan 2018 20:14:27 +0100 Subject: [PATCH 22/84] Fix model test --- bda/tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bda/tests/test_models.py b/bda/tests/test_models.py index d9242b3f..95ce8646 100644 --- a/bda/tests/test_models.py +++ b/bda/tests/test_models.py @@ -67,7 +67,7 @@ class SpectacleReventeTests(TestCase): revente = self.rev wanted_by = [self.p1, self.p2, self.p3] - revente.answered_mail = wanted_by + revente.confirmed_entry = wanted_by with mock.patch('bda.models.random.choice') as mc: # Set winner to self.p1. From 6059ca067b96f3eb2071f022a39ef2d7d1cc5cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 15 Jan 2018 05:26:33 +0100 Subject: [PATCH 23/84] Speed up tests ~20% less using MD5 and force_login in kfet testcase. ~77% less by disabling the debug tollbar. --- cof/settings/common.py | 3 +++ cof/settings/dev.py | 22 ++++++++++++++-------- kfet/tests/testcases.py | 8 +------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/cof/settings/common.py b/cof/settings/common.py index 48242fc3..708e90a0 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -7,6 +7,7 @@ the local development server should be here. """ import os +import sys try: from . import secret @@ -53,6 +54,8 @@ BASE_DIR = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) +TESTING = sys.argv[1] == 'test' + # Application definition INSTALLED_APPS = [ diff --git a/cof/settings/dev.py b/cof/settings/dev.py index 6e1f6b11..114f37da 100644 --- a/cof/settings/dev.py +++ b/cof/settings/dev.py @@ -4,13 +4,18 @@ The settings that are not listed here are imported from .common """ from .common import * # NOQA -from .common import INSTALLED_APPS, MIDDLEWARE +from .common import INSTALLED_APPS, MIDDLEWARE, TESTING EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEBUG = True +if TESTING: + PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', + ] + # --- # Apache static/media config @@ -36,12 +41,13 @@ def show_toolbar(request): """ return DEBUG -INSTALLED_APPS += ["debug_toolbar", "debug_panel"] +if not TESTING: + INSTALLED_APPS += ["debug_toolbar", "debug_panel"] -MIDDLEWARE = [ - "debug_panel.middleware.DebugPanelMiddleware" -] + MIDDLEWARE + MIDDLEWARE = [ + "debug_panel.middleware.DebugPanelMiddleware" + ] + MIDDLEWARE -DEBUG_TOOLBAR_CONFIG = { - 'SHOW_TOOLBAR_CALLBACK': show_toolbar, -} + DEBUG_TOOLBAR_CONFIG = { + 'SHOW_TOOLBAR_CALLBACK': show_toolbar, + } diff --git a/kfet/tests/testcases.py b/kfet/tests/testcases.py index 3ea428c3..aa2fb1b6 100644 --- a/kfet/tests/testcases.py +++ b/kfet/tests/testcases.py @@ -245,13 +245,7 @@ class ViewTestCaseMixin(TestCaseMixin): self.register_user(label, user) if self.auth_user: - # The wrapper is a sanity check. - self.assertTrue( - self.client.login( - username=self.auth_user, - password=self.auth_user, - ) - ) + self.client.force_login(self.users[self.auth_user]) def tearDown(self): del self.users_base From e23e1bdba6c6ddbc4e80e23b2099c7e6e99788f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 15 Jan 2018 16:52:46 +0100 Subject: [PATCH 24/84] kfet -- Add test to check the choices of checkouts in K-Psul Particularly, it adds a regression test for #184. --- kfet/tests/test_forms.py | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 kfet/tests/test_forms.py diff --git a/kfet/tests/test_forms.py b/kfet/tests/test_forms.py new file mode 100644 index 00000000..e946d39d --- /dev/null +++ b/kfet/tests/test_forms.py @@ -0,0 +1,48 @@ +import datetime +from unittest import mock + +from django.test import TestCase +from django.utils import timezone + +from kfet.forms import KPsulCheckoutForm +from kfet.models import Checkout + +from .utils import create_user + + +class KPsulCheckoutFormTests(TestCase): + + def setUp(self): + self.now = timezone.now() + + user = create_user() + + self.c1 = Checkout.objects.create( + name='C1', balance=10, + created_by=user.profile.account_kfet, + valid_from=self.now, + valid_to=self.now + datetime.timedelta(days=1), + ) + + self.form = KPsulCheckoutForm() + + def test_checkout(self): + checkout_f = self.form.fields['checkout'] + self.assertListEqual(list(checkout_f.choices), [ + ('', '---------'), + (self.c1.pk, 'C1'), + ]) + + @mock.patch('django.utils.timezone.now') + def test_checkout_valid(self, mock_now): + """ + Checkout are filtered using the current datetime. + Regression test for #184. + """ + self.now += datetime.timedelta(days=2) + mock_now.return_value = self.now + + form = KPsulCheckoutForm() + + checkout_f = form.fields['checkout'] + self.assertListEqual(list(checkout_f.choices), [('', '---------')]) From 525bb4d16dc7057fa746d5e66c9c8ebb336466b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 15 Jan 2018 16:56:38 +0100 Subject: [PATCH 25/84] kfet -- Fix available checkouts in K-Psul The checkout validity is checked using the current datetime (when requesting the kpsul page). --- kfet/forms.py | 16 ++++++++-------- kfet/models.py | 9 +++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/kfet/forms.py b/kfet/forms.py index 963e4254..26774b1c 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -296,17 +296,17 @@ class KPsulAccountForm(forms.ModelForm): class KPsulCheckoutForm(forms.Form): checkout = forms.ModelChoiceField( - queryset=( - Checkout.objects - .filter( - is_protected=False, - valid_from__lte=timezone.now(), - valid_to__gte=timezone.now(), - ) - ), + queryset=None, widget=forms.Select(attrs={'id': 'id_checkout_select'}), ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Create the queryset on form instanciation to use the current time. + self.fields['checkout'].queryset = ( + Checkout.objects.is_valid().filter(is_protected=False)) + class KPsulOperationForm(forms.ModelForm): article = forms.ModelChoiceField( diff --git a/kfet/models.py b/kfet/models.py index deee76eb..c9de48bc 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -341,6 +341,13 @@ class AccountNegative(models.Model): return self.start + kfet_config.overdraft_duration +class CheckoutQuerySet(models.QuerySet): + + def is_valid(self): + now = timezone.now() + return self.filter(valid_from__lte=now, valid_to__gte=now) + + class Checkout(models.Model): created_by = models.ForeignKey( Account, on_delete = models.PROTECT, @@ -353,6 +360,8 @@ class Checkout(models.Model): default = 0) is_protected = models.BooleanField(default = False) + objects = CheckoutQuerySet.as_manager() + def get_absolute_url(self): return reverse('kfet.checkout.read', kwargs={'pk': self.pk}) From 478f56d94b6db8a185bbd0dd821147c06a2bfafc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Tue, 16 Jan 2018 15:59:17 +0100 Subject: [PATCH 26/84] kfet -- Create initial statement on checkout save - Why? Because it should be the actual behavior. - To allow using arithmetic operations with values of DecimalField when object are not retrieved from DB, some strings are replaced by Decimal or int. If you wonder why it's not automatically done, see: https://code.djangoproject.com/ticket/27825 --- kfet/models.py | 16 ++++++++++++++++ kfet/tests/test_models.py | 37 ++++++++++++++++++++++++++++++++++++- kfet/tests/test_views.py | 19 ++++++++++++------- kfet/views.py | 10 +--------- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/kfet/models.py b/kfet/models.py index deee76eb..3f38cc44 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -362,6 +362,22 @@ class Checkout(models.Model): def __str__(self): return self.name + def save(self, *args, **kwargs): + created = self.pk is None + + ret = super().save(*args, **kwargs) + + if created: + self.statements.create( + amount_taken=0, + balance_old=self.balance, + balance_new=self.balance, + by=self.created_by, + ) + + return ret + + class CheckoutTransfer(models.Model): from_checkout = models.ForeignKey( Checkout, on_delete = models.PROTECT, diff --git a/kfet/tests/test_models.py b/kfet/tests/test_models.py index ea132acd..727cac4e 100644 --- a/kfet/tests/test_models.py +++ b/kfet/tests/test_models.py @@ -1,7 +1,12 @@ +import datetime + from django.contrib.auth import get_user_model from django.test import TestCase +from django.utils import timezone -from kfet.models import Account +from kfet.models import Account, Checkout + +from .utils import create_user User = get_user_model() @@ -23,3 +28,33 @@ class AccountTests(TestCase): with self.assertRaises(Account.DoesNotExist): Account.objects.get_by_password('bernard') + + +class CheckoutTests(TestCase): + + def setUp(self): + self.now = timezone.now() + + self.u = create_user() + self.u_acc = self.u.profile.account_kfet + + self.c = Checkout( + created_by=self.u_acc, + valid_from=self.now, + valid_to=self.now + datetime.timedelta(days=1), + ) + + def test_initial_statement(self): + """A statement is added with initial balance on creation.""" + self.c.balance = 10 + self.c.save() + + st = self.c.statements.get() + self.assertEqual(st.balance_new, 10) + self.assertEqual(st.amount_taken, 0) + self.assertEqual(st.amount_error, 0) + + # Saving again doesn't create a new statement. + self.c.save() + + self.assertEqual(self.c.statements.count(), 1) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index 41ed8b5c..40f895a1 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -746,12 +746,16 @@ class CheckoutReadViewTests(ViewTestCaseMixin, TestCase): def setUp(self): super().setUp() - self.checkout = Checkout.objects.create( - name='Checkout', - created_by=self.accounts['team'], - valid_from=self.now, - valid_to=self.now + timedelta(days=5), - ) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = self.now + + self.checkout = Checkout.objects.create( + name='Checkout', balance=Decimal('10'), + created_by=self.accounts['team'], + valid_from=self.now, + valid_to=self.now + timedelta(days=1), + ) def test_ok(self): r = self.client.get(self.url) @@ -794,7 +798,7 @@ class CheckoutUpdateViewTests(ViewTestCaseMixin, TestCase): name='Checkout', valid_from=self.now, valid_to=self.now + timedelta(days=5), - balance='3.14', + balance=Decimal('3.14'), is_protected=False, created_by=self.accounts['team'], ) @@ -864,6 +868,7 @@ class CheckoutStatementListViewTests(ViewTestCaseMixin, TestCase): self.assertQuerysetEqual( r.context['checkoutstatements'], map(repr, expected_statements), + ordered=False, ) diff --git a/kfet/views.py b/kfet/views.py index 1fe9ac22..2b69684d 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -528,15 +528,7 @@ class CheckoutCreate(SuccessMessageMixin, CreateView): # Creating form.instance.created_by = self.request.user.profile.account_kfet - checkout = form.save() - - # Création d'un relevé avec balance initiale - CheckoutStatement.objects.create( - checkout = checkout, - by = self.request.user.profile.account_kfet, - balance_old = checkout.balance, - balance_new = checkout.balance, - amount_taken = 0) + form.save() return super(CheckoutCreate, self).form_valid(form) From 42e762bc4a07cde8eee2a9e12ef9e51ce1b75322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Tue, 16 Jan 2018 16:22:52 +0100 Subject: [PATCH 27/84] Py3 allows to shorten super() --- bda/admin.py | 2 +- bda/forms.py | 8 ++++---- bda/views.py | 2 +- gestioncof/admin.py | 4 ++-- gestioncof/forms.py | 28 +++++++++++++------------- gestioncof/petits_cours_forms.py | 4 ++-- gestioncof/petits_cours_views.py | 2 +- gestioncof/widgets.py | 2 +- kfet/forms.py | 20 +++++++++---------- kfet/models.py | 8 ++++---- kfet/views.py | 34 ++++++++++++++++---------------- 11 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bda/admin.py b/bda/admin.py index 0e796f57..5511cf85 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -164,7 +164,7 @@ class AttributionAdminForm(forms.ModelForm): ) def clean(self): - cleaned_data = super(AttributionAdminForm, self).clean() + cleaned_data = super().clean() participant = cleaned_data.get("participant") spectacle = cleaned_data.get("spectacle") if participant and spectacle: diff --git a/bda/forms.py b/bda/forms.py index 3b0dd5bd..a363c36e 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -52,7 +52,7 @@ class ResellForm(forms.Form): required=False) def __init__(self, participant, *args, **kwargs): - super(ResellForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['attributions'].queryset = ( participant.attribution_set .filter(spectacle__date__gte=timezone.now()) @@ -70,7 +70,7 @@ class AnnulForm(forms.Form): required=False) def __init__(self, participant, *args, **kwargs): - super(AnnulForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['attributions'].queryset = ( participant.attribution_set .filter(spectacle__date__gte=timezone.now(), @@ -89,7 +89,7 @@ class InscriptionReventeForm(forms.Form): required=False) def __init__(self, tirage, *args, **kwargs): - super(InscriptionReventeForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['spectacles'].queryset = ( tirage.spectacle_set .select_related('location') @@ -104,7 +104,7 @@ class SoldForm(forms.Form): widget=forms.CheckboxSelectMultiple) def __init__(self, participant, *args, **kwargs): - super(SoldForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['attributions'].queryset = ( participant.attribution_set .filter(revente__isnull=False, diff --git a/bda/views.py b/bda/views.py index 4d18a043..dace3c51 100644 --- a/bda/views.py +++ b/bda/views.py @@ -628,7 +628,7 @@ class SpectacleListView(ListView): return categories def get_context_data(self, **kwargs): - context = super(SpectacleListView, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context['tirage_id'] = self.tirage.id context['tirage_name'] = self.tirage.title return context diff --git a/gestioncof/admin.py b/gestioncof/admin.py index 51969822..54a6a5a0 100644 --- a/gestioncof/admin.py +++ b/gestioncof/admin.py @@ -181,7 +181,7 @@ class UserProfileAdmin(UserAdmin): def get_fieldsets(self, request, user=None): if not request.user.is_superuser: return self.staff_fieldsets - return super(UserProfileAdmin, self).get_fieldsets(request, user) + return super().get_fieldsets(request, user) def save_model(self, request, user, form, change): cof_group, created = Group.objects.get_or_create(name='COF') @@ -267,7 +267,7 @@ class PetitCoursDemandeAdmin(admin.ModelAdmin): class ClubAdminForm(forms.ModelForm): def clean(self): - cleaned_data = super(ClubAdminForm, self).clean() + cleaned_data = super().clean() respos = cleaned_data.get('respos') members = cleaned_data.get('membres') for respo in respos.all(): diff --git a/gestioncof/forms.py b/gestioncof/forms.py index 2124b7c8..0d1db499 100644 --- a/gestioncof/forms.py +++ b/gestioncof/forms.py @@ -18,7 +18,7 @@ class EventForm(forms.Form): event = kwargs.pop("event") self.event = event current_choices = kwargs.pop("current_choices", None) - super(EventForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) choices = {} if current_choices: for choice in current_choices.all(): @@ -60,7 +60,7 @@ class SurveyForm(forms.Form): def __init__(self, *args, **kwargs): survey = kwargs.pop("survey") current_answers = kwargs.pop("current_answers", None) - super(SurveyForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) answers = {} if current_answers: for answer in current_answers.all(): @@ -100,7 +100,7 @@ class SurveyForm(forms.Form): class SurveyStatusFilterForm(forms.Form): def __init__(self, *args, **kwargs): survey = kwargs.pop("survey") - super(SurveyStatusFilterForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for question in survey.questions.all(): for answer in question.answers.all(): name = "question_%d_answer_%d" % (question.id, answer.id) @@ -129,7 +129,7 @@ class SurveyStatusFilterForm(forms.Form): class EventStatusFilterForm(forms.Form): def __init__(self, *args, **kwargs): event = kwargs.pop("event") - super(EventStatusFilterForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for option in event.options.all(): for choice in option.choices.all(): name = "option_%d_choice_%d" % (option.id, choice.id) @@ -175,12 +175,12 @@ class UserProfileForm(forms.ModelForm): last_name = forms.CharField(label=_('Nom'), max_length=30) def __init__(self, *args, **kw): - super(UserProfileForm, self).__init__(*args, **kw) + super().__init__(*args, **kw) self.fields['first_name'].initial = self.instance.user.first_name self.fields['last_name'].initial = self.instance.user.last_name def save(self, *args, **kw): - super(UserProfileForm, self).save(*args, **kw) + super().save(*args, **kw) self.instance.user.first_name = self.cleaned_data.get('first_name') self.instance.user.last_name = self.cleaned_data.get('last_name') self.instance.user.save() @@ -193,7 +193,7 @@ class UserProfileForm(forms.ModelForm): class RegistrationUserForm(forms.ModelForm): def __init__(self, *args, **kw): - super(RegistrationUserForm, self).__init__(*args, **kw) + super().__init__(*args, **kw) self.fields['username'].help_text = "" class Meta: @@ -219,8 +219,7 @@ class RegistrationPassUserForm(RegistrationUserForm): return pass2 def save(self, commit=True, *args, **kwargs): - user = super(RegistrationPassUserForm, self).save(commit, *args, - **kwargs) + user = super().save(commit, *args, **kwargs) user.set_password(self.cleaned_data['password2']) if commit: user.save() @@ -229,7 +228,7 @@ class RegistrationPassUserForm(RegistrationUserForm): class RegistrationProfileForm(forms.ModelForm): def __init__(self, *args, **kw): - super(RegistrationProfileForm, self).__init__(*args, **kw) + super().__init__(*args, **kw) self.fields['mailing_cof'].initial = True self.fields['mailing_bda'].initial = True self.fields['mailing_bda_revente'].initial = True @@ -274,7 +273,7 @@ class AdminEventForm(forms.Form): kwargs["initial"] = {"status": "wait"} else: kwargs["initial"] = {"status": "no"} - super(AdminEventForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) choices = {} for choice in current_choices: if choice.event_option.id not in choices: @@ -337,14 +336,15 @@ class BaseEventRegistrationFormset(BaseFormSet): self.events = kwargs.pop('events') self.current_registrations = kwargs.pop('current_registrations', None) self.extra = len(self.events) - super(BaseEventRegistrationFormset, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def _construct_form(self, index, **kwargs): kwargs['event'] = self.events[index] if self.current_registrations is not None: kwargs['current_registration'] = self.current_registrations[index] - return super(BaseEventRegistrationFormset, self)._construct_form( - index, **kwargs) + return super()._construct_form(index, **kwargs) + + EventFormset = formset_factory(AdminEventForm, BaseEventRegistrationFormset) diff --git a/gestioncof/petits_cours_forms.py b/gestioncof/petits_cours_forms.py index c0770afc..e8f067bf 100644 --- a/gestioncof/petits_cours_forms.py +++ b/gestioncof/petits_cours_forms.py @@ -10,7 +10,7 @@ from gestioncof.petits_cours_models import PetitCoursDemande, PetitCoursAbility class BaseMatieresFormSet(BaseInlineFormSet): def clean(self): - super(BaseMatieresFormSet, self).clean() + super().clean() if any(self.errors): # Don't bother validating the formset unless each form is # valid on its own @@ -34,7 +34,7 @@ class DemandeForm(ModelForm): captcha = ReCaptchaField(attrs={'theme': 'clean', 'lang': 'fr'}) def __init__(self, *args, **kwargs): - super(DemandeForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['matieres'].help_text = '' class Meta: diff --git a/gestioncof/petits_cours_views.py b/gestioncof/petits_cours_views.py index ec3d1a58..6b8c8610 100644 --- a/gestioncof/petits_cours_views.py +++ b/gestioncof/petits_cours_views.py @@ -42,7 +42,7 @@ class DemandeDetailView(DetailView): context_object_name = "demande" def get_context_data(self, **kwargs): - context = super(DemandeDetailView, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) obj = self.object context['attributions'] = obj.petitcoursattribution_set.all() return context diff --git a/gestioncof/widgets.py b/gestioncof/widgets.py index 134ddd80..1741cfec 100644 --- a/gestioncof/widgets.py +++ b/gestioncof/widgets.py @@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe class TriStateCheckbox(Widget): def __init__(self, attrs=None, choices=()): - super(TriStateCheckbox, self).__init__(attrs) + super().__init__(attrs) # choices can be any iterable, but we may need to render this widget # multiple times. Thus, collapse it into a list so it can be consumed # more than once. diff --git a/kfet/forms.py b/kfet/forms.py index 5cfef918..417a51a7 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -42,7 +42,7 @@ class AccountForm(forms.ModelForm): # Surcharge pour passer data à Account.save() def save(self, data = {}, *args, **kwargs): - obj = super(AccountForm, self).save(commit = False, *args, **kwargs) + obj = super().save(commit = False, *args, **kwargs) obj.save(data = data) return obj @@ -91,7 +91,7 @@ class AccountPwdForm(forms.Form): raise ValidationError("Mot de passe trop court") if pwd1 != pwd2: raise ValidationError("Les mots de passes sont différents") - super(AccountPwdForm, self).clean() + super().clean() class CofForm(forms.ModelForm): def clean_is_cof(self): @@ -195,7 +195,7 @@ class CheckoutStatementCreateForm(forms.ModelForm): or self.cleaned_data['balance_200'] is None or self.cleaned_data['balance_500'] is None): raise ValidationError("Y'a un problème. Si tu comptes la caisse, mets au moins des 0 stp (et t'as pas idée de comment c'est long de vérifier que t'as mis des valeurs de partout...)") - super(CheckoutStatementCreateForm, self).clean() + super().clean() class CheckoutStatementUpdateForm(forms.ModelForm): class Meta: @@ -236,7 +236,7 @@ class ArticleForm(forms.ModelForm): required = False) def __init__(self, *args, **kwargs): - super(ArticleForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance.pk: self.initial['suppliers'] = self.instance.suppliers.values_list('pk', flat=True) @@ -250,7 +250,7 @@ class ArticleForm(forms.ModelForm): category, _ = ArticleCategory.objects.get_or_create(name=category_new) self.cleaned_data['category'] = category - super(ArticleForm, self).clean() + super().clean() class Meta: model = Article @@ -321,7 +321,7 @@ class KPsulOperationForm(forms.ModelForm): } def clean(self): - super(KPsulOperationForm, self).clean() + super().clean() type_ope = self.cleaned_data.get('type') amount = self.cleaned_data.get('amount') article = self.cleaned_data.get('article') @@ -364,7 +364,7 @@ class AddcostForm(forms.Form): raise ValidationError('Compte invalide') else: self.cleaned_data['amount'] = 0 - super(AddcostForm, self).clean() + super().clean() # ----- @@ -462,7 +462,7 @@ class InventoryArticleForm(forms.Form): stock_new = forms.IntegerField(required=False) def __init__(self, *args, **kwargs): - super(InventoryArticleForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if 'initial' in kwargs: self.name = kwargs['initial']['name'] self.stock_old = kwargs['initial']['stock_old'] @@ -484,7 +484,7 @@ class OrderArticleForm(forms.Form): quantity_ordered = forms.IntegerField(required=False) def __init__(self, *args, **kwargs): - super(OrderArticleForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if 'initial' in kwargs: self.name = kwargs['initial']['name'] self.stock = kwargs['initial']['stock'] @@ -514,7 +514,7 @@ class OrderArticleToInventoryForm(forms.Form): quantity_received = forms.IntegerField() def __init__(self, *args, **kwargs): - super(OrderArticleToInventoryForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if 'initial' in kwargs: self.name = kwargs['initial']['name'] self.category = kwargs['initial']['category'] diff --git a/kfet/models.py b/kfet/models.py index e5aed2b7..5cd4b757 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -255,7 +255,7 @@ class Account(models.Model): cof.save() if data: self.cofprofile = cof - super(Account, self).save(*args, **kwargs) + super().save(*args, **kwargs) def change_pwd(self, clear_password): from .auth.utils import hash_password @@ -423,7 +423,7 @@ class CheckoutStatement(models.Model): self.balance_new + self.amount_taken - self.balance_old) with transaction.atomic(): Checkout.objects.filter(pk=checkout_id).update(balance=self.balance_new) - super(CheckoutStatement, self).save(*args, **kwargs) + super().save(*args, **kwargs) else: self.amount_error = ( self.balance_new + self.amount_taken - self.balance_old) @@ -437,7 +437,7 @@ class CheckoutStatement(models.Model): and last_statement.balance_new != self.balance_new): Checkout.objects.filter(pk=self.checkout_id).update( balance=F('balance') - last_statement.balance_new + self.balance_new) - super(CheckoutStatement, self).save(*args, **kwargs) + super().save(*args, **kwargs) class ArticleCategory(models.Model): @@ -538,7 +538,7 @@ class InventoryArticle(models.Model): # d'erreur if not self.inventory.order: self.stock_error = self.stock_new - self.stock_old - super(InventoryArticle, self).save(*args, **kwargs) + super().save(*args, **kwargs) class Supplier(models.Model): diff --git a/kfet/views.py b/kfet/views.py index ef1885b3..df25c95e 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -493,7 +493,7 @@ class AccountNegativeList(ListView): context_object_name = 'negatives' def get_context_data(self, **kwargs): - context = super(AccountNegativeList, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) real_balances = (neg.account.real_balance for neg in self.object_list) context['negatives_sum'] = sum(real_balances) return context @@ -536,7 +536,7 @@ class CheckoutCreate(SuccessMessageMixin, CreateView): balance_new = checkout.balance, amount_taken = 0) - return super(CheckoutCreate, self).form_valid(form) + return super().form_valid(form) # Checkout - Read @@ -546,7 +546,7 @@ class CheckoutRead(DetailView): context_object_name = 'checkout' def get_context_data(self, **kwargs): - context = super(CheckoutRead, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context['statements'] = context['checkout'].statements.order_by('-at') return context @@ -565,7 +565,7 @@ class CheckoutUpdate(SuccessMessageMixin, UpdateView): form.add_error(None, 'Permission refusée') return self.form_invalid(form) # Updating - return super(CheckoutUpdate, self).form_valid(form) + return super().form_valid(form) # ----- # Checkout Statement views @@ -617,7 +617,7 @@ class CheckoutStatementCreate(SuccessMessageMixin, CreateView): at = self.object.at) def get_context_data(self, **kwargs): - context = super(CheckoutStatementCreate, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) checkout = Checkout.objects.get(pk=self.kwargs['pk_checkout']) context['checkout'] = checkout return context @@ -633,7 +633,7 @@ class CheckoutStatementCreate(SuccessMessageMixin, CreateView): form.instance.balance_new = getAmountBalance(form.cleaned_data) form.instance.checkout_id = self.kwargs['pk_checkout'] form.instance.by = self.request.user.profile.account_kfet - return super(CheckoutStatementCreate, self).form_valid(form) + return super().form_valid(form) class CheckoutStatementUpdate(SuccessMessageMixin, UpdateView): model = CheckoutStatement @@ -645,7 +645,7 @@ class CheckoutStatementUpdate(SuccessMessageMixin, UpdateView): return reverse_lazy('kfet.checkout.read', kwargs={'pk':self.kwargs['pk_checkout']}) def get_context_data(self, **kwargs): - context = super(CheckoutStatementUpdate, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) checkout = Checkout.objects.get(pk=self.kwargs['pk_checkout']) context['checkout'] = checkout return context @@ -657,7 +657,7 @@ class CheckoutStatementUpdate(SuccessMessageMixin, UpdateView): return self.form_invalid(form) # Updating form.instance.amount_taken = getAmountTaken(form.instance) - return super(CheckoutStatementUpdate, self).form_valid(form) + return super().form_valid(form) # ----- # Category views @@ -689,7 +689,7 @@ class CategoryUpdate(SuccessMessageMixin, UpdateView): return self.form_invalid(form) # Updating - return super(CategoryUpdate, self).form_valid(form) + return super().form_valid(form) # ----- # Article views @@ -756,7 +756,7 @@ class ArticleCreate(SuccessMessageMixin, CreateView): ) # Creating - return super(ArticleCreate, self).form_valid(form) + return super().form_valid(form) # Article - Read @@ -766,7 +766,7 @@ class ArticleRead(DetailView): context_object_name = 'article' def get_context_data(self, **kwargs): - context = super(ArticleRead, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) inventoryarts = (InventoryArticle.objects .filter(article=self.object) .select_related('inventory') @@ -818,7 +818,7 @@ class ArticleUpdate(SuccessMessageMixin, UpdateView): article=article, supplier=supplier) # Updating - return super(ArticleUpdate, self).form_valid(form) + return super().form_valid(form) # ----- @@ -1709,7 +1709,7 @@ class InventoryRead(DetailView): context_object_name = 'inventory' def get_context_data(self, **kwargs): - context = super(InventoryRead, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) inventoryarticles = (InventoryArticle.objects .select_related('article', 'article__category') .filter(inventory = self.object) @@ -1727,7 +1727,7 @@ class OrderList(ListView): context_object_name = 'orders' def get_context_data(self, **kwargs): - context = super(OrderList, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context['suppliers'] = Supplier.objects.order_by('name') return context @@ -1856,7 +1856,7 @@ class OrderRead(DetailView): context_object_name = 'order' def get_context_data(self, **kwargs): - context = super(OrderRead, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) orderarticles = (OrderArticle.objects .select_related('article', 'article__category') .filter(order=self.object) @@ -2010,7 +2010,7 @@ class SupplierUpdate(SuccessMessageMixin, UpdateView): form.add_error(None, 'Permission refusée') return self.form_invalid(form) # Updating - return super(SupplierUpdate, self).form_valid(form) + return super().form_valid(form) # ========== @@ -2274,7 +2274,7 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView): @method_decorator(login_required) def dispatch(self, *args, **kwargs): - return super(AccountStatBalance, self).dispatch(*args, **kwargs) + return super().dispatch(*args, **kwargs) # ------------------------ From 776ff28141dc96e2ab13e8b35bb71f42637dae3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 17:48:00 +0100 Subject: [PATCH 28/84] cof -- Add helpers to test cof views. --- gestioncof/tests/__init__.py | 0 gestioncof/{tests.py => tests/test_legacy.py} | 0 gestioncof/tests/testcases.py | 24 ++ gestioncof/tests/utils.py | 51 +++ shared/tests/testcases.py | 294 ++++++++++++++++++ 5 files changed, 369 insertions(+) create mode 100644 gestioncof/tests/__init__.py rename gestioncof/{tests.py => tests/test_legacy.py} (100%) create mode 100644 gestioncof/tests/testcases.py create mode 100644 gestioncof/tests/utils.py create mode 100644 shared/tests/testcases.py diff --git a/gestioncof/tests/__init__.py b/gestioncof/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gestioncof/tests.py b/gestioncof/tests/test_legacy.py similarity index 100% rename from gestioncof/tests.py rename to gestioncof/tests/test_legacy.py diff --git a/gestioncof/tests/testcases.py b/gestioncof/tests/testcases.py new file mode 100644 index 00000000..b53f2866 --- /dev/null +++ b/gestioncof/tests/testcases.py @@ -0,0 +1,24 @@ +from shared.tests.testcases import ViewTestCaseMixin as BaseViewTestCaseMixin + +from .utils import create_user, create_member, create_staff + + +class ViewTestCaseMixin(BaseViewTestCaseMixin): + """ + TestCase extension to ease testing of cof views. + + Most information can be found in the base parent class doc. + This class performs some changes to users management, detailed below. + + During setup, three users are created: + - 'user': a basic user without any permission, + - 'member': (profile.is_cof is True), + - 'staff': (profile.is_cof is True) && (profile.is_buro is True). + """ + + def get_users_base(self): + return { + 'user': create_user('user'), + 'member': create_member('member'), + 'staff': create_staff('staff'), + } diff --git a/gestioncof/tests/utils.py b/gestioncof/tests/utils.py new file mode 100644 index 00000000..8d55680a --- /dev/null +++ b/gestioncof/tests/utils.py @@ -0,0 +1,51 @@ +from django.contrib.auth import get_user_model + +User = get_user_model() + + +def _create_user(username, is_cof=False, is_staff=False, attrs=None): + if attrs is None: + attrs = {} + + password = attrs.pop('password', username) + + user_keys = ['first_name', 'last_name', 'email', 'is_staff'] + user_attrs = {k: v for k, v in attrs.items() if k in user_keys} + + profile_keys = [ + 'is_cof', 'login_clipper', 'phone', 'occupation', 'departement', + 'type_cotiz', 'mailing_cof', 'mailing_bda', 'mailing_bda_revente', + 'comments', 'is_buro', 'petit_cours_accept', + 'petit_cours_remarques', + ] + profile_attrs = {k: v for k, v in attrs.items() if k in profile_keys} + + if is_cof: + profile_attrs['is_cof'] = True + + if is_staff: + # At the moment, admin is accessible by COF staff. + user_attrs['is_staff'] = True + profile_attrs['is_buro'] = True + + user = User(username=username, **user_attrs) + user.set_password(password) + user.save() + + for k, v in profile_attrs.items(): + setattr(user.profile, k, v) + user.profile.save() + + return user + + +def create_user(username, attrs=None): + return _create_user(username, attrs=attrs) + + +def create_member(username, attrs=None): + return _create_user(username, is_cof=True, attrs=attrs) + + +def create_staff(username, attrs=None): + return _create_user(username, is_cof=True, is_staff=True, attrs=attrs) diff --git a/shared/tests/testcases.py b/shared/tests/testcases.py new file mode 100644 index 00000000..15792383 --- /dev/null +++ b/shared/tests/testcases.py @@ -0,0 +1,294 @@ +from unittest import mock +from urllib.parse import parse_qs, urlparse + +from django.contrib.auth import get_user_model +from django.core.urlresolvers import reverse +from django.http import QueryDict +from django.test import Client +from django.utils import timezone +from django.utils.functional import cached_property + +User = get_user_model() + + +class TestCaseMixin: + + def assertForbidden(self, response): + """ + Test that the response (retrieved with a Client) is a denial of access. + + The response should verify one of the following: + - its HTTP response code is 403, + - it redirects to the login page with a GET parameter named 'next' + whose value is the url of the requested page. + + """ + request = response.wsgi_request + + try: + try: + # Is this an HTTP Forbidden response ? + self.assertEqual(response.status_code, 403) + except AssertionError: + # A redirection to the login view is fine too. + + # Let's build the login url with the 'next' param on current + # page. + full_path = request.get_full_path() + + querystring = QueryDict(mutable=True) + querystring['next'] = full_path + + login_url = '{}?{}'.format( + reverse('cof-login'), querystring.urlencode(safe='/')) + + # We don't focus on what the login view does. + # So don't fetch the redirect. + self.assertRedirects( + response, login_url, + fetch_redirect_response=False, + ) + except AssertionError: + raise AssertionError( + "%(http_method)s request at %(path)s should be forbidden for " + "%(username)s user.\n" + "Response isn't 403, nor a redirect to login view. Instead, " + "response code is %(code)d." % { + 'http_method': request.method, + 'path': request.get_full_path(), + 'username': ( + "'{}'".format(request.user) + if request.user.is_authenticated() + else 'anonymous' + ), + 'code': response.status_code, + } + ) + + def assertUrlsEqual(self, actual, expected): + """ + Test that the url 'actual' is as 'expected'. + + Arguments: + actual (str): Url to verify. + expected: Two forms are accepted. + * (str): Expected url. Strings equality is checked. + * (dict): Its keys must be attributes of 'urlparse(actual)'. + Equality is checked for each present key, except for + 'query' which must be a dict of the expected query string + parameters. + + """ + if type(expected) == dict: + parsed = urlparse(actual) + for part, expected_part in expected.items(): + if part == 'query': + self.assertDictEqual( + parse_qs(parsed.query), + expected.get('query', {}), + ) + else: + self.assertEqual(getattr(parsed, part), expected_part) + else: + self.assertEqual(actual, expected) + + +class ViewTestCaseMixin(TestCaseMixin): + """ + TestCase extension to ease tests of kfet views. + + + Urls concerns + ------------- + + # Basic usage + + Attributes: + url_name (str): Name of view under test, as given to 'reverse' + function. + url_args (list, optional): Will be given to 'reverse' call. + url_kwargs (dict, optional): Same. + url_expcted (str): What 'reverse' should return given previous + attributes. + + View url can then be accessed at the 'url' attribute. + + # Advanced usage + + If multiple combinations of url name, args, kwargs can be used for a view, + it is possible to define 'urls_conf' attribute. It must be a list whose + each item is a dict defining arguments for 'reverse' call ('name', 'args', + 'kwargs' keys) and its expected result ('expected' key). + + The reversed urls can be accessed at the 't_urls' attribute. + + + Users concerns + -------------- + + During setup, the following users are created: + - 'user': a basic user without any permission, + - 'root': a superuser, account trigramme: 200. + Their password is their username. + + One can create additionnal users with 'get_users_extra' method, or prevent + these users to be created with 'get_users_base' method. See these two + methods for further informations. + + By using 'register_user' method, these users can then be accessed at + 'users' attribute by their label. + + A user label can be given to 'auth_user' attribute. The related user is + then authenticated on self.client during test setup. Its value defaults to + 'None', meaning no user is authenticated. + + + Automated tests + --------------- + + # Url reverse + + Based on url-related attributes/properties, the test 'test_urls' checks + that expected url is returned by 'reverse' (once with basic url usage and + each for advanced usage). + + # Forbidden responses + + The 'test_forbidden' test verifies that each user, from labels of + 'auth_forbidden' attribute, can't access the url(s), i.e. response should + be a 403, or a redirect to login view. + + Tested HTTP requests are given by 'http_methods' attribute. Additional data + can be given by defining an attribute '_data'. + + """ + url_name = None + url_expected = None + + http_methods = ['GET'] + + auth_user = None + auth_forbidden = [] + + def setUp(self): + """ + Warning: Do not forget to call super().setUp() in subclasses. + """ + # 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) + + # A test can mock 'django.utils.timezone.now' and give this as return + # value. E.g. it is useful if the test checks values of 'auto_now' or + # 'auto_now_add' fields. + self.now = timezone.now() + + # Register of User instances. + self.users = {} + + for label, user in dict(self.users_base, **self.users_extra).items(): + self.register_user(label, user) + + if self.auth_user: + # The wrapper is a sanity check. + self.assertTrue( + self.client.login( + username=self.auth_user, + password=self.auth_user, + ) + ) + + def tearDown(self): + del self.users_base + del self.users_extra + + def get_users_base(self): + """ + Dict of . + + Note: Don't access yourself this property. Use 'users_base' attribute + which cache the returned value from here. + It allows to give functions calls, which creates users instances, as + values here. + + """ + return { + 'user': User.objects.create_user('user', '', 'user'), + 'root': User.objects.create_superuser('root', '', 'root'), + } + + @cached_property + def users_base(self): + return self.get_users_base() + + def get_users_extra(self): + """ + Dict of . + + Note: Don't access yourself this property. Use 'users_base' attribute + which cache the returned value from here. + It allows to give functions calls, which create users instances, as + values here. + + """ + return {} + + @cached_property + def users_extra(self): + return self.get_users_extra() + + def register_user(self, label, user): + self.users[label] = user + + def get_user(self, label): + if self.auth_user is not None: + return self.auth_user + return self.auth_user_mapping.get(label) + + @property + def urls_conf(self): + return [{ + 'name': self.url_name, + 'args': getattr(self, 'url_args', []), + 'kwargs': getattr(self, 'url_kwargs', {}), + 'expected': self.url_expected, + }] + + @property + def t_urls(self): + return [ + reverse( + url_conf['name'], + args=url_conf.get('args', []), + kwargs=url_conf.get('kwargs', {}), + ) + for url_conf in self.urls_conf] + + @property + def url(self): + return self.t_urls[0] + + def test_urls(self): + for url, conf in zip(self.t_urls, self.urls_conf): + self.assertEqual(url, conf['expected']) + + def test_forbidden(self): + for method in self.http_methods: + for user in self.auth_forbidden: + for url in self.t_urls: + self.check_forbidden(method, url, user) + + def check_forbidden(self, method, url, user=None): + method = method.lower() + client = Client() + if user is not None: + client.login(username=user, password=user) + + send_request = getattr(client, method) + data = getattr(self, '{}_data'.format(method), {}) + + r = send_request(url, data) + self.assertForbidden(r) From 57de31d59a82906b935237f533844397efa5e4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 17:57:43 +0100 Subject: [PATCH 29/84] cof -- Add tests for survey views --- gestioncof/tests/test_views.py | 178 +++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..b8860811 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,178 @@ +from django.contrib import messages +from django.contrib.messages import get_messages +from django.contrib.messages.storage.base import Message +from django.test import TestCase + +from gestioncof.models import Survey, SurveyAnswer +from gestioncof.tests.testcases import ViewTestCaseMixin + + +class SurveyViewTests(ViewTestCaseMixin, TestCase): + url_name = 'survey.details' + + http_methods = ['GET', 'POST'] + + auth_user = 'user' + auth_forbidden = [None] + + post_expected_message = Message(messages.SUCCESS, ( + "Votre réponse a bien été enregistrée ! Vous pouvez cependant la " + "modifier jusqu'à la fin du sondage." + )) + + @property + def url_kwargs(self): + return {'survey_id': self.s.pk} + + @property + def url_expected(self): + return '/survey/{}'.format(self.s.pk) + + def setUp(self): + super().setUp() + + self.s = Survey.objects.create(title='Title') + + self.q1 = self.s.questions.create(question='Question 1 ?') + self.q2 = self.s.questions.create( + question='Question 2 ?', + multi_answers=True, + ) + + self.qa1 = self.q1.answers.create(answer='Q1 - Answer 1') + self.qa2 = self.q1.answers.create(answer='Q1 - Answer 2') + self.qa3 = self.q2.answers.create(answer='Q2 - Answer 1') + self.qa4 = self.q2.answers.create(answer='Q2 - Answer 2') + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_new(self): + r = self.client.post(self.url, { + 'question_{}'.format(self.q1.pk): [str(self.qa1.pk)], + 'question_{}'.format(self.q2.pk): [ + str(self.qa3.pk), str(self.qa4.pk), + ], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + a = self.s.surveyanswer_set.get(user=self.users['user']) + self.assertQuerysetEqual( + a.answers.all(), map(repr, [self.qa1, self.qa3, self.qa4]), + ordered=False, + ) + + def test_post_edit(self): + a = self.s.surveyanswer_set.create(user=self.users['user']) + a.answers.add(self.qa1, self.qa1, self.qa4) + + r = self.client.post(self.url, { + 'question_{}'.format(self.q1.pk): [], + 'question_{}'.format(self.q2.pk): [str(self.qa3.pk)], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + a.refresh_from_db() + self.assertQuerysetEqual( + a.answers.all(), map(repr, [self.qa3]), + ordered=False, + ) + + def test_post_delete(self): + a = self.s.surveyanswer_set.create(user=self.users['user']) + a.answers.add(self.qa1, self.qa4) + + r = self.client.post(self.url, {'delete': '1'}) + + self.assertEqual(r.status_code, 200) + expected_message = Message( + messages.SUCCESS, "Votre réponse a bien été supprimée") + self.assertIn(expected_message, get_messages(r.wsgi_request)) + + with self.assertRaises(SurveyAnswer.DoesNotExist): + a.refresh_from_db() + + def test_forbidden_closed(self): + self.s.survey_open = False + self.s.save() + + r = self.client.get(self.url) + + self.assertNotEqual(r.status_code, 200) + + def test_forbidden_old(self): + self.s.old = True + self.s.save() + + r = self.client.get(self.url) + + self.assertNotEqual(r.status_code, 200) + + +class SurveyStatusViewTests(ViewTestCaseMixin, TestCase): + url_name = 'survey.details.status' + + http_methods = ['GET', 'POST'] + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + @property + def url_kwargs(self): + return {'survey_id': self.s.pk} + + @property + def url_expected(self): + return '/survey/{}/status'.format(self.s.pk) + + def setUp(self): + super().setUp() + + self.s = Survey.objects.create(title='Title') + + self.q1 = self.s.questions.create(question='Question 1 ?') + self.q2 = self.s.questions.create( + question='Question 2 ?', + multi_answers=True, + ) + + self.qa1 = self.q1.answers.create(answer='Q1 - Answer 1') + self.qa2 = self.q1.answers.create(answer='Q1 - Answer 2') + self.qa3 = self.q2.answers.create(answer='Q2 - Answer 1') + self.qa4 = self.q2.answers.create(answer='Q2 - Answer 2') + + self.a1 = self.s.surveyanswer_set.create(user=self.users['user']) + self.a1.answers.add(self.qa1) + self.a2 = self.s.surveyanswer_set.create(user=self.users['member']) + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def _get_qa_filter_name(self, qa): + return 'question_{}_answer_{}'.format(qa.survey_question.pk, qa.pk) + + def _test_filters(self, filters, expected): + r = self.client.post(self.url, { + self._get_qa_filter_name(qa): v for qa, v in filters + }) + + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['user_answers'], map(repr, expected), + ordered=False, + ) + + def test_filter_none(self): + self._test_filters([(self.qa1, 'none')], [self.a1, self.a2]) + + def test_filter_yes(self): + self._test_filters([(self.qa1, 'yes')], [self.a1]) + + def test_filter_no(self): + self._test_filters([(self.qa1, 'no')], [self.a2]) From 8675948d9e849acc028e79c78f7aed92b68101ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:01:36 +0100 Subject: [PATCH 30/84] cof -- Fix urls naming in survey templates --- gestioncof/templates/gestioncof/survey.html | 2 +- gestioncof/templates/survey_status.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gestioncof/templates/gestioncof/survey.html b/gestioncof/templates/gestioncof/survey.html index ccf447ef..9d4d67b3 100644 --- a/gestioncof/templates/gestioncof/survey.html +++ b/gestioncof/templates/gestioncof/survey.html @@ -8,7 +8,7 @@ {% if survey.details %}

    {{ survey.details }}

    {% endif %} - + {% csrf_token %} {{ form | bootstrap}} diff --git a/gestioncof/templates/survey_status.html b/gestioncof/templates/survey_status.html index 831a07bb..0e630c6e 100644 --- a/gestioncof/templates/survey_status.html +++ b/gestioncof/templates/survey_status.html @@ -11,7 +11,7 @@ {% endif %}

    Filtres

    {% include "tristate_js.html" %} - + {% csrf_token %} {{ form.as_p }} From ce734990776fe25f62be96b05ad589027bde0a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:15:57 +0100 Subject: [PATCH 31/84] Fix use of Widget.build_attrs in TriStateCheckbox Signature changed in Django 1.11. --- gestioncof/widgets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gestioncof/widgets.py b/gestioncof/widgets.py index 758fc4ad..cbc9cd93 100644 --- a/gestioncof/widgets.py +++ b/gestioncof/widgets.py @@ -20,6 +20,7 @@ class TriStateCheckbox(Widget): def render(self, name, value, attrs=None, choices=()): if value is None: value = 'none' - final_attrs = self.build_attrs(attrs, value=value) + attrs['value'] = value + final_attrs = self.build_attrs(self.attrs, attrs) output = ["" % flatatt(final_attrs)] return mark_safe('\n'.join(output)) From f5b280896fcaac00934e2341552ca4f3e21bda05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:36:03 +0100 Subject: [PATCH 32/84] cof -- Add tests for event views --- gestioncof/tests/test_views.py | 163 +++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..a3c423d3 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,163 @@ +from django.contrib import messages +from django.contrib.messages import get_messages +from django.contrib.messages.storage.base import Message +from django.test import TestCase + +from gestioncof.models import Event +from gestioncof.tests.testcases import ViewTestCaseMixin + + +class EventViewTests(ViewTestCaseMixin, TestCase): + url_name = 'event.details' + + http_methods = ['GET', 'POST'] + + auth_user = 'user' + auth_forbidden = [None] + + post_expected_message = Message(messages.SUCCESS, ( + "Votre inscription a bien été enregistrée ! Vous pouvez cependant la " + "modifier jusqu'à la fin des inscriptions." + )) + + @property + def url_kwargs(self): + return {'event_id': self.e.pk} + + @property + def url_expected(self): + return '/event/{}'.format(self.e.pk) + + def setUp(self): + super().setUp() + + self.e = Event.objects.create() + + self.ecf1 = self.e.commentfields.create(name='Comment Field 1') + self.ecf2 = self.e.commentfields.create( + name='Comment Field 2', fieldtype='char', + ) + + self.o1 = self.e.options.create(name='Option 1') + self.o2 = self.e.options.create(name='Option 2', multi_choices=True) + + self.oc1 = self.o1.choices.create(value='O1 - Choice 1') + self.oc2 = self.o1.choices.create(value='O1 - Choice 2') + self.oc3 = self.o2.choices.create(value='O2 - Choice 1') + self.oc4 = self.o2.choices.create(value='O2 - Choice 2') + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_new(self): + r = self.client.post(self.url, { + 'option_{}'.format(self.o1.pk): [str(self.oc1.pk)], + 'option_{}'.format(self.o2.pk): [ + str(self.oc3.pk), str(self.oc4.pk), + ], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + er = self.e.eventregistration_set.get(user=self.users['user']) + self.assertQuerysetEqual( + er.options.all(), map(repr, [self.oc1, self.oc3, self.oc4]), + ordered=False, + ) + # TODO: Make the view care about comments. + # self.assertQuerysetEqual( + # er.comments.all(), map(repr, []), + # ordered=False, + # ) + + def test_post_edit(self): + er = self.e.eventregistration_set.create(user=self.users['user']) + er.options.add(self.oc1, self.oc3, self.oc4) + er.comments.create( + commentfield=self.ecf1, content='Comment 1', + ) + + r = self.client.post(self.url, { + 'option_{}'.format(self.o1.pk): [], + 'option_{}'.format(self.o2.pk): [str(self.oc3.pk)], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + er.refresh_from_db() + self.assertQuerysetEqual( + er.options.all(), map(repr, [self.oc3]), + ordered=False, + ) + # TODO: Make the view care about comments. + # self.assertQuerysetEqual( + # er.comments.all(), map(repr, []), + # ordered=False, + # ) + + +class EventStatusViewTests(ViewTestCaseMixin, TestCase): + url_name = 'event.details.status' + + http_methods = ['GET', 'POST'] + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + @property + def url_kwargs(self): + return {'event_id': self.e.pk} + + @property + def url_expected(self): + return '/event/{}/status'.format(self.e.pk) + + def setUp(self): + super().setUp() + + self.e = Event.objects.create() + + self.cf1 = self.e.commentfields.create(name='Comment Field 1') + self.cf2 = self.e.commentfields.create( + name='Comment Field 2', fieldtype='char', + ) + + self.o1 = self.e.options.create(name='Option 1') + self.o2 = self.e.options.create(name='Option 2', multi_choices=True) + + self.oc1 = self.o1.choices.create(value='O1 - Choice 1') + self.oc2 = self.o1.choices.create(value='O1 - Choice 2') + self.oc3 = self.o2.choices.create(value='O2 - Choice 1') + self.oc4 = self.o2.choices.create(value='O2 - Choice 2') + + self.er1 = self.e.eventregistration_set.create(user=self.users['user']) + self.er1.options.add(self.oc1) + self.er2 = self.e.eventregistration_set.create( + user=self.users['member'], + ) + + def _get_oc_filter_name(self, oc): + return 'option_{}_choice_{}'.format(oc.event_option.pk, oc.pk) + + def _test_filters(self, filters, expected): + r = self.client.post(self.url, { + self._get_oc_filter_name(oc): v for oc, v in filters + }) + + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['user_choices'], map(repr, expected), + ordered=False, + ) + + def test_filter_none(self): + self._test_filters([(self.oc1, 'none')], [self.er1, self.er2]) + + def test_filter_yes(self): + self._test_filters([(self.oc1, 'yes')], [self.er1]) + + def test_filter_no(self): + self._test_filters([(self.oc1, 'no')], [self.er2]) From a6f52cfdc561fa436c474706c3f770805c43954f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:38:34 +0100 Subject: [PATCH 33/84] cof -- Fix urls naming in event template --- gestioncof/templates/event_status.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gestioncof/templates/event_status.html b/gestioncof/templates/event_status.html index 2a09b820..40bda7db 100644 --- a/gestioncof/templates/event_status.html +++ b/gestioncof/templates/event_status.html @@ -11,7 +11,7 @@ {% endif %} {% include "tristate_js.html" %}

    Filtres

    - + {% csrf_token %} {{ form.as_p }} From dfb9ccb0af1b8700823e929d85806c06c769979a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:15:57 +0100 Subject: [PATCH 34/84] Fix use of Widget.build_attrs in TriStateCheckbox Signature changed in Django 1.11. --- gestioncof/widgets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gestioncof/widgets.py b/gestioncof/widgets.py index 758fc4ad..cbc9cd93 100644 --- a/gestioncof/widgets.py +++ b/gestioncof/widgets.py @@ -20,6 +20,7 @@ class TriStateCheckbox(Widget): def render(self, name, value, attrs=None, choices=()): if value is None: value = 'none' - final_attrs = self.build_attrs(attrs, value=value) + attrs['value'] = value + final_attrs = self.build_attrs(self.attrs, attrs) output = ["" % flatatt(final_attrs)] return mark_safe('\n'.join(output)) From c239f28f171fc899ca9b6824e003e24833bf482f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 20 Jan 2018 17:02:23 +0100 Subject: [PATCH 35/84] syncmails should be able to be silent --- gestioncof/management/commands/syncmails.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/gestioncof/management/commands/syncmails.py b/gestioncof/management/commands/syncmails.py index 1d3dddb8..74ad152d 100644 --- a/gestioncof/management/commands/syncmails.py +++ b/gestioncof/management/commands/syncmails.py @@ -63,8 +63,9 @@ class Command(BaseCommand): except CustomMail.DoesNotExist: mail = CustomMail.objects.create(**fields) status['synced'] += 1 - self.stdout.write( - 'SYNCED {:s}'.format(fields['shortname'])) + if options['verbosity']: + self.stdout.write( + 'SYNCED {:s}'.format(fields['shortname'])) assoc['mails'][obj['pk']] = mail # Variables @@ -79,8 +80,9 @@ class Command(BaseCommand): except Variable.DoesNotExist: Variable.objects.create(**fields) - # C'est agréable d'avoir le résultat affiché - self.stdout.write( - '{synced:d} mails synchronized {unchanged:d} unchanged' - .format(**status) - ) + if options['verbosity']: + # C'est agréable d'avoir le résultat affiché + self.stdout.write( + '{synced:d} mails synchronized {unchanged:d} unchanged' + .format(**status) + ) From 4084444dc3c1fd5f5af2e5714cee2fc163da1180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 20 Jan 2018 17:29:15 +0100 Subject: [PATCH 36/84] Fix autocomplete in registration views. django-autocomplete-light v3.x doesn't include anymore the $('').yourlabsAutocomplete() function, leading to issues in cof registration and kfet account creation views. Adding jquery-autocomplete-light fixes these issues. See: - (dal) https://github.com/yourlabs/django-autocomplete-light - (jal) https://github.com/yourlabs/jquery-autocomplete-light --- cof/settings/common.py | 2 + gestioncof/templates/registration.html | 2 +- kfet/templates/kfet/account_create.html | 2 +- .../3.5.0/dist/jquery.autocomplete-light.js | 1698 +++++++++++++++++ .../dist/jquery.autocomplete-light.min.js | 9 + 5 files changed, 1711 insertions(+), 2 deletions(-) create mode 100644 shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.js create mode 100644 shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js diff --git a/cof/settings/common.py b/cof/settings/common.py index 48242fc3..aec299c1 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -56,6 +56,8 @@ BASE_DIR = os.path.dirname( # Application definition INSTALLED_APPS = [ + 'shared', + 'gestioncof', # Must be before 'django.contrib.admin'. diff --git a/gestioncof/templates/registration.html b/gestioncof/templates/registration.html index 8f05dfb0..99ab3e73 100644 --- a/gestioncof/templates/registration.html +++ b/gestioncof/templates/registration.html @@ -4,7 +4,7 @@ {% block page_size %}col-sm-8{% endblock %} {% block extra_head %} - + {% endblock %} {% block realcontent %} diff --git a/kfet/templates/kfet/account_create.html b/kfet/templates/kfet/account_create.html index 59fc1d56..b09713df 100644 --- a/kfet/templates/kfet/account_create.html +++ b/kfet/templates/kfet/account_create.html @@ -5,7 +5,7 @@ {% block header-title %}Création d'un compte{% endblock %} {% block extra_head %} - + {% endblock %} {% block main %} diff --git a/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.js b/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.js new file mode 100644 index 00000000..a916bff5 --- /dev/null +++ b/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.js @@ -0,0 +1,1698 @@ +/* + * jquery-autocomplete-light - v3.5.0 + * Dead simple autocompletion and widgets for jQuery + * http://yourlabs.org + * + * Made by James Pic + * Under MIT License + */ +/* +Here is the list of the major difference with other autocomplete scripts: + +- don't do anything but fire a signal when a choice is selected: it's +left as an exercise to the developer to implement whatever he wants when +that happens +- don't generate the autocomplete HTML, it should be generated by the server + +Let's establish the vocabulary used in this script, so that we speak the +same language: + +- The text input element is "input", +- The box that contains a list of choices is "box", +- Each result in the "autocomplete" is a "choice", +- With a capital A, "Autocomplete", is the class or an instance of the +class. + +Here is a fantastic schema in ASCII art: + + +---------------------+ <----- Input + | Your city name ? <---------- Placeholder + +---------------------+ + | Paris, France | <----- Autocomplete + | Paris, TX, USA | + | Paris, TN, USA | + | Paris, KY, USA <------------ Choice + | Paris, IL, USA | + +---------------------+ + +This script defines three signals: + +- hilightChoice: when a choice is hilight, or that the user +navigates into a choice with the keyboard, +- dehilightChoice: when a choice was hilighed, and that the user +navigates into another choice with the keyboard or mouse, +- selectChoice: when the user clicks on a choice, or that he pressed +enter on a hilighted choice. + +They all work the same, here's a trivial example: + + $('#your-autocomplete').bind( + 'selectChoice', + function(e, choice, autocomplete) { + alert('You selected: ' + choice.html()); + } + ); + +Note that 'e' is the variable containing the event object. + +Also, note that this script is composed of two main parts: + +- The Autocomplete class that handles all interaction, defined as +`Autocomplete`, +- The jQuery plugin that manages Autocomplete instance, defined as +`$.fn.yourlabsAutocomplete` +*/ + +if (window.isOpera === undefined) { + var isOpera = (navigator.userAgent.indexOf('Opera')>=0) && parseFloat(navigator.appVersion); +} + +if (window.isIE === undefined) { + var isIE = ((document.all) && (!isOpera)) && parseFloat(navigator.appVersion.split('MSIE ')[1].split(';')[0]); +} + +if (window.findPosX === undefined) { + window.findPosX = function(obj) { + var curleft = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curleft += obj.offsetLeft - ((isOpera) ? 0 : obj.scrollLeft); + obj = obj.offsetParent; + } + // IE offsetParent does not include the top-level + if (isIE && obj.parentElement){ + curleft += obj.offsetLeft - obj.scrollLeft; + } + } else if (obj.x) { + curleft += obj.x; + } + return curleft; + } +} + +if (window.findPosY === undefined) { + window.findPosY = function(obj) { + var curtop = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curtop += obj.offsetTop - ((isOpera) ? 0 : obj.scrollTop); + obj = obj.offsetParent; + } + // IE offsetParent does not include the top-level + if (isIE && obj.parentElement){ + curtop += obj.offsetTop - obj.scrollTop; + } + } else if (obj.y) { + curtop += obj.y; + } + return curtop; + } +} + +// Our class will live in the yourlabs global namespace. +if (window.yourlabs === undefined) window.yourlabs = {}; + +// Fix #25: Prevent accidental inclusion of autocomplete_light/static.html +if (window.yourlabs.Autocomplete !== undefined) + console.log('WARNING ! You are loading autocomplete.js **again**.'); + +yourlabs.getInternetExplorerVersion = function() +// Returns the version of Internet Explorer or a -1 +// (indicating the use of another browser). +{ + var rv = -1; // Return value assumes failure. + if (navigator.appName === 'Microsoft Internet Explorer') + { + var ua = navigator.userAgent; + var re = new RegExp('MSIE ([0-9]{1,}[.0-9]{0,})'); + if (re.exec(ua) !== null) + rv = parseFloat( RegExp.$1 ); + } + return rv; +}; + +$.fn.yourlabsRegistry = function(key, value) { + var ie = yourlabs.getInternetExplorerVersion(); + + if (ie === -1 || ie > 8) { + // If not on IE8 and friends, that's all we need to do. + return value === undefined ? this.data(key) : this.data(key, value); + } + + if ($.fn.yourlabsRegistry.data === undefined) { + $.fn.yourlabsRegistry.data = {}; + } + + if ($.fn.yourlabsRegistry.guid === undefined) { + $.fn.yourlabsRegistry.guid = function() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + function(c) { + var r = Math.random()*16|0, v = c === 'x' ? r : (r&0x3|0x8); + return v.toString(16); + } + ); + }; + } + + var attributeName = 'data-yourlabs-' + key + '-registry-id'; + var id = this.attr(attributeName); + + if (id === undefined) { + id = $.fn.yourlabsRegistry.guid(); + this.attr(attributeName, id); + } + + if (value !== undefined) { + $.fn.yourlabsRegistry.data[id] = value; + } + + return $.fn.yourlabsRegistry.data[id]; +}; + +/* +The autocomplete class constructor: + +- takes a takes a text input element as argument, +- sets attributes and methods for this instance. + +The reason you want to learn about all this script is that you will then be +able to override any variable or function in it on a case-per-case basis. +However, overriding is the job of the jQuery plugin so the procedure is +described there. +*/ +yourlabs.Autocomplete = function (input) { + /* + The text input element that should have an autocomplete. + */ + this.input = input; + + // The value of the input. It is kept as an attribute for optimisation + // purposes. + this.value = null; + + /* + It is possible to wait until a certain number of characters have been + typed in the input before making a request to the server, to limit the + number of requests. + + However, you may want the autocomplete to behave like a select. If you + want that a simple click shows the autocomplete, set this to 0. + */ + this.minimumCharacters = 2; + + /* + In a perfect world, we would hide the autocomplete when the input looses + focus (on blur). But in reality, if the user clicks on a choice, the + input looses focus, and that would hide the autocomplete, *before* we + can intercept the click on the choice. + + When the input looses focus, wait for this number of milliseconds before + hiding the autocomplete. + */ + this.hideAfter = 200; + + /* + Normally the autocomplete box aligns with the left edge of the input. To + align with the right edge of the input instead, change this variable. + */ + this.alignRight = false; + + /* + The server should have a URL that takes the input value, and responds + with the list of choices as HTML. In most cases, an absolute URL is + better. + */ + this.url = false; + + /* + Although this script will make sure that it doesn't have multiple ajax + requests at the time, it also supports debouncing. + + Set a number of milliseconds here, it is the number of milliseconds that it + will wait before querying the server. The higher it is, the less it will + spam the server but the more the user will wait. + */ + this.xhrWait = 200; + + /* + As the server responds with plain HTML, we need a selector to find the + choices that it contains. + + For example, if the URL returns an HTML body where every result is in a + div of class "choice", then this should be set to '.choice'. + */ + this.choiceSelector = '.choice'; + + /* + When the user hovers a choice, it is nice to hilight it, for + example by changing it's background color. That's the job of CSS code. + + However, the CSS can not depend on the :hover because the user can + hilight choices with the keyboard by pressing the up and down + keys. + + To counter that problem, we specify a particular class that will be set + on a choice when it's 'hilighted', and unset when it's + 'dehilighted'. + */ + this.hilightClass = 'hilight'; + + /* + You can set this variable to true if you want the first choice + to be hilighted by default. + */ + this.autoHilightFirst = false; + + + /* + You can set this variable to false in order to allow opening of results + in new tabs or windows + */ + this.bindMouseDown = true; + + /* + The value of the input is passed to the server via a GET variable. This + is the name of the variable. + */ + this.queryVariable = 'q'; + + /* + This dict will also be passed to the server as GET variables. + + If this autocomplete depends on another user defined value, then the + other user defined value should be set in this dict. + + Consider a country select and a city autocomplete. The city autocomplete + should only fetch city choices that are in the selected country. To + achieve this, update the data with the value of the country select: + + $('select[name=country]').change(function() { + $('city[name=country]').yourlabsAutocomplete().data = { + country: $(this).val(), + } + }); + */ + this.data = {}; + + /* + To avoid several requests to be pending at the same time, the current + request is aborted before a new one is sent. This attribute will hold the + current XMLHttpRequest. + */ + this.xhr = false; + + /* + fetch() keeps a copy of the data sent to the server in this attribute. This + avoids double fetching the same autocomplete. + */ + this.lastData = {}; + + // The autocomplete box HTML. + this.box = $(''); + + /* + We'll append the box to the container and calculate an absolute position + every time the autocomplete is shown in the fixPosition method. + + By default, this traverses this.input's parents to find the nearest parent + with an 'absolute' or 'fixed' position. This prevents scrolling issues. If + we can't find a parent that would be correct to append to, default to + . + */ + this.container = this.input.parents().filter(function() { + var position = $(this).css('position'); + return position === 'absolute' || position === 'fixed'; + }).first(); + if (!this.container.length) this.container = $('body'); +}; + +/* +Rather than directly setting up the autocomplete (DOM events etc ...) in +the constructor, setup is done in this method. This allows to: + +- instanciate an Autocomplete, +- override attribute/methods of the instance, +- and *then* setup the instance. + */ +yourlabs.Autocomplete.prototype.initialize = function() { + var ie = yourlabs.getInternetExplorerVersion(); + + this.input + .on('blur.autocomplete', $.proxy(this.inputBlur, this)) + .on('focus.autocomplete', $.proxy(this.inputClick, this)) + .on('keydown.autocomplete', $.proxy(this.inputKeyup, this)); + + $(window).on('resize', $.proxy(function() { + if (this.box.is(':visible')) this.fixPosition(); + }, this)); + + // Currently, our positioning doesn't work well in Firefox. Since it's not + // the first option on mobile phones and small devices, we'll hide the bug + // until this is fixed. + if (/Firefox/i.test(navigator.userAgent)) + $(window).on('scroll', $.proxy(this.hide, this)); + + if (ie === -1 || ie > 9) { + this.input.on('input.autocomplete', $.proxy(this.refresh, this)); + } + else + { + var events = [ + 'keyup.autocomplete', + 'keypress.autocomplete', + 'cut.autocomplete', + 'paste.autocomplete' + ] + + this.input.on(events.join(' '), function($e) { + $.proxy(this.inputKeyup, this); + }) + } + + /* + Bind mouse events to fire signals. Because the same signals will be + sent if the user uses keyboard to work with the autocomplete. + */ + this.box + .on('mouseenter', this.choiceSelector, $.proxy(this.boxMouseenter, this)) + .on('mouseleave', this.choiceSelector, $.proxy(this.boxMouseleave, this)); + if(this.bindMouseDown){ + this.box + .on('mousedown', this.choiceSelector, $.proxy(this.boxClick, this)); + } + + /* + Initially - empty data queried + */ + this.data[this.queryVariable] = ''; +}; + +// Unbind callbacks on input. +yourlabs.Autocomplete.prototype.destroy = function(input) { + input + .unbind('blur.autocomplete') + .unbind('focus.autocomplete') + .unbind('input.autocomplete') + .unbind('keydown.autocomplete') + .unbind('keypress.autocomplete') + .unbind('keyup.autocomplete') +}; + +yourlabs.Autocomplete.prototype.inputBlur = function(e) { + window.setTimeout($.proxy(this.hide, this), this.hideAfter); +}; + +yourlabs.Autocomplete.prototype.inputClick = function(e) { + if (this.value === null) + this.value = this.getQuery(); + + if (this.value.length >= this.minimumCharacters) + this.show(); +}; + +// When mouse enters the box: +yourlabs.Autocomplete.prototype.boxMouseenter = function(e) { + // ... the first thing we want is to send the dehilight signal + // for any hilighted choice ... + var current = this.box.find('.' + this.hilightClass); + + this.input.trigger('dehilightChoice', + [current, this]); + + // ... and then sent the hilight signal for the choice. + this.input.trigger('hilightChoice', + [$(e.currentTarget), this]); +}; + +// When mouse leaves the box: +yourlabs.Autocomplete.prototype.boxMouseleave = function(e) { + // Send dehilightChoice when the mouse leaves a choice. + this.input.trigger('dehilightChoice', + [this.box.find('.' + this.hilightClass), this]); +}; + +// When mouse clicks in the box: +yourlabs.Autocomplete.prototype.boxClick = function(e) { + var current = this.box.find('.' + this.hilightClass); + + this.input.trigger('selectChoice', [current, this]); +}; + +// Return the value to pass to this.queryVariable. +yourlabs.Autocomplete.prototype.getQuery = function() { + // Return the input's value by default. + return this.input.val(); +}; + +yourlabs.Autocomplete.prototype.inputKeyup = function(e) { + if (!this.input.is(':visible')) + // Don't handle keypresses on hidden inputs (ie. with limited choices) + return; + + switch(e.keyCode) { + case 40: // down arrow + case 38: // up arrow + case 16: // shift + case 17: // ctrl + case 18: // alt + this.move(e); + break; + + case 9: // tab + case 13: // enter + if (!this.box.is(':visible')) return; + + var choice = this.box.find('.' + this.hilightClass); + + if (!choice.length) { + // Don't get in the way, let the browser submit form or focus + // on next element. + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this.input.trigger('selectChoice', [choice, this]); + break; + + case 27: // escape + if (!this.box.is(':visible')) return; + this.hide(); + break; + + default: + this.refresh(); + } +}; + +// This function is in charge of ensuring that a relevant autocomplete is +// shown. +yourlabs.Autocomplete.prototype.show = function(html) { + // First recalculate the absolute position since the autocomplete may + // have changed position. + this.fixPosition(); + + // Is autocomplete empty ? + var empty = $.trim(this.box.find(this.choiceSelector)).length === 0; + + // If the inner container is empty or data has changed and there is no + // current pending request, rely on fetch(), which should show the + // autocomplete as soon as it's done fetching. + if ((this.hasChanged() || empty) && !this.xhr) { + this.fetch(); + return; + } + + // And actually, fetch() will call show() with the response + // body as argument. + if (html !== undefined) { + this.box.html(html); + this.fixPosition(); + } + + // Don't display empty boxes. + if (this.box.is(':empty')) { + if (this.box.is(':visible')) { + this.hide(); + } + return; + } + + var current = this.box.find('.' + this.hilightClass); + var first = this.box.find(this.choiceSelector + ':first'); + if (first && !current.length && this.autoHilightFirst) { + first.addClass(this.hilightClass); + } + + // Show the inner and outer container only if necessary. + if (!this.box.is(':visible')) { + this.box.css('display', 'block'); + this.fixPosition(); + } +}; + +// This function is in charge of the opposite. +yourlabs.Autocomplete.prototype.hide = function() { + this.box.hide(); +}; + +// This function is in charge of hilighting the right result from keyboard +// navigation. +yourlabs.Autocomplete.prototype.move = function(e) { + if (this.value === null) + this.value = this.getQuery(); + + // If the autocomplete should not be displayed then return. + if (this.value.length < this.minimumCharacters) return true; + + // The current choice if any. + var current = this.box.find('.' + this.hilightClass); + + // Prevent default browser behaviours on TAB and RETURN if a choice is + // hilighted. + if ($.inArray(e.keyCode, [9,13]) > -1 && current.length) { + e.preventDefault(); + } + + // If not KEY_UP or KEY_DOWN, then return. + // NOTE: with Webkit, both keyCode and charCode are set to 38/40 for &/(. + // charCode is 0 for arrow keys. + // Ref: http://stackoverflow.com/a/12046935/15690 + var way; + if (e.keyCode === 38 && !e.charCode) way = 'up'; + else if (e.keyCode === 40 && !e.charCode) way = 'down'; + else return; + + // The first and last choices. If the user presses down on the last + // choice, then the first one will be hilighted. + var first = this.box.find(this.choiceSelector + ':first'); + var last = this.box.find(this.choiceSelector + ':last'); + + // The choice that should be hilighted after the move. + var target; + + // The autocomplete must be shown so that the user sees what choice + // he is hilighting. + this.show(); + + // If a choice is currently hilighted: + if (current.length) { + if (way === 'up') { + // The target choice becomes the first previous choice. + target = current.prevAll(this.choiceSelector + ':first'); + + // If none, then the last choice becomes the target. + if (!target.length) target = last; + } else { + // The target choice becomes the first next** choice. + target = current.nextAll(this.choiceSelector + ':first'); + + // If none, then the first choice becomes the target. + if (!target.length) target = first; + } + + // Trigger dehilightChoice on the currently hilighted choice. + this.input.trigger('dehilightChoice', + [current, this]); + } else { + target = way === 'up' ? last : first; + } + + // Avoid moving the cursor in the input. + e.preventDefault(); + + // Trigger hilightChoice on the target choice. + this.input.trigger('hilightChoice', + [target, this]); +}; + +/* +Calculate and set the outer container's absolute positionning. We're copying +the system from Django admin's JS widgets like the date calendar, which means: + +- the autocomplete box is an element appended to this.co, +- +*/ +yourlabs.Autocomplete.prototype.fixPosition = function() { + var el = this.input.get(0); + + var zIndex = this.input.parents().filter(function() { + return $(this).css('z-index') !== 'auto' && $(this).css('z-index') !== '0'; + }).first().css('z-index'); + + var absolute_parent = this.input.parents().filter(function(){ + return $(this).css('position') === 'absolute'; + }).get(0); + + var top = (findPosY(el) + this.input.outerHeight()) + 'px'; + var left = findPosX(el) + 'px'; + + if(absolute_parent !== undefined){ + var parentTop = findPosY(absolute_parent); + var parentLeft = findPosX(absolute_parent); + var inputBottom = findPosY(el) + this.input.outerHeight(); + var inputLeft = findPosX(el); + top = (inputBottom - parentTop) + 'px'; + left = (inputLeft - parentLeft) + 'px'; + } + + if (this.alignRight) { + left = (findPosX(el) + el.scrollLeft - (this.box.outerWidth() - this.input.outerWidth())) + 'px'; + } + + this.box.appendTo(this.container).css({ + position: 'absolute', + minWidth: parseInt(this.input.outerWidth()), + top: top, + left: left, + zIndex: zIndex + }); +}; + +// Proxy fetch(), with some sanity checks. +yourlabs.Autocomplete.prototype.refresh = function() { + // Set the new current value. + this.value = this.getQuery(); + + // If the input doesn't contain enought characters then abort, else fetch. + if (this.value.length < this.minimumCharacters) + this.hide(); + else + this.fetch(); +}; + +// Return true if the data for this query has changed from last query. +yourlabs.Autocomplete.prototype.hasChanged = function() { + for(var key in this.data) { + if (!(key in this.lastData) || this.data[key] !== this.lastData[key]) { + return true; + } + } + return false; +}; + +// Manage requests to this.url. +yourlabs.Autocomplete.prototype.fetch = function() { + // Add the current value to the data dict. + this.data[this.queryVariable] = this.value; + + // Ensure that this request is different from the previous one + if (!this.hasChanged()) { + // Else show the same box again. + this.show(); + return; + } + + this.lastData = {}; + for(var key in this.data) { + this.lastData[key] = this.data[key]; + } + + // Abort any unsent requests. + if (this.xhr && this.xhr.readyState === 0) this.xhr.abort(); + + // Abort any request that we planned to make. + if (this.timeoutId) clearTimeout(this.timeoutId); + + // Make an asynchronous GET request to this.url in this.xhrWait ms + this.timeoutId = setTimeout($.proxy(this.makeXhr, this), this.xhrWait); +}; + +// Wrapped ajax call to use with setTimeout in fetch(). +yourlabs.Autocomplete.prototype.makeXhr = function() { + this.input.addClass('xhr-pending'); + + this.xhr = $.ajax(this.url, { + type: 'GET', + data: this.data, + complete: $.proxy(this.fetchComplete, this) + }); +}; + +// Callback for the ajax response. +yourlabs.Autocomplete.prototype.fetchComplete = function(jqXHR, textStatus) { + if (this.xhr === jqXHR) { + // Current request finished. + this.xhr = false; + } else { + // Ignore response from earlier request. + return; + } + + // Current request done, nothing else pending. + this.input.removeClass('xhr-pending'); + + if (textStatus === 'abort') return; + this.show(jqXHR.responseText); +}; + +/* +The jQuery plugin that manages Autocomplete instances across the various +inputs. It is named 'yourlabsAutocomplete' rather than just 'autocomplete' +to live happily with other plugins that may define an autocomplete() jQuery +plugin. + +It takes an array as argument, the array may contain any attribute or +function that should override the Autocomplete builtin. For example: + + $('input#your-autocomplete').yourlabsAutocomplete({ + url: '/some/url/', + hide: function() { + this.outerContainer + }, + }) + +Also, it implements a simple identity map, which means that: + + // First call for an input instanciates the Autocomplete instance + $('input#your-autocomplete').yourlabsAutocomplete({ + url: '/some/url/', + }); + + // Other calls return the previously created Autocomplete instance + $('input#your-autocomplete').yourlabsAutocomplete().data = { + newData: $('#foo').val(), + } + +To destroy an autocomplete, call yourlabsAutocomplete('destroy'). +*/ +$.fn.yourlabsAutocomplete = function(overrides) { + if (this.length < 1) { + // avoid crashing when called on a non existing element + return; + } + + overrides = overrides ? overrides : {}; + var autocomplete = this.yourlabsRegistry('autocomplete'); + + if (overrides === 'destroy') { + if (autocomplete) { + autocomplete.destroy(this); + this.removeData('autocomplete'); + } + return; + } + + // Disable the browser's autocomplete features on that input. + this.attr('autocomplete', 'off'); + + // If no Autocomplete instance is defined for this id, make one. + if (autocomplete === undefined) { + // Instanciate Autocomplete. + autocomplete = new yourlabs.Autocomplete(this); + + // Extend the instance with data-autocomplete-* overrides + for (var key in this.data()) { + if (!key) continue; + if (key.substr(0, 12) !== 'autocomplete' || key === 'autocomplete') + continue; + var newKey = key.replace('autocomplete', ''); + newKey = newKey.charAt(0).toLowerCase() + newKey.slice(1); + autocomplete[newKey] = this.data(key); + } + + // Extend the instance with overrides. + autocomplete = $.extend(autocomplete, overrides); + + if (!autocomplete.url) { + alert('Autocomplete needs a url !'); + return; + } + + this.yourlabsRegistry('autocomplete', autocomplete); + + // All set, call initialize(). + autocomplete.initialize(); + } + + // Return the Autocomplete instance for this id from the registry. + return autocomplete; +}; + +// Binding some default behaviors. +$(document).ready(function() { + function removeHilightClass(e, choice, autocomplete) { + choice.removeClass(autocomplete.hilightClass); + } + $(document).bind('hilightChoice', function(e, choice, autocomplete) { + choice.addClass(autocomplete.hilightClass); + }); + $(document).bind('dehilightChoice', removeHilightClass); + $(document).bind('selectChoice', removeHilightClass); + $(document).bind('selectChoice', function(e, choice, autocomplete) { + autocomplete.hide(); + }); +}); + +$(document).ready(function() { + /* Credit: django.contrib.admin (BSD) */ + + var showAddAnotherPopup = function(triggeringLink) { + var name = triggeringLink.attr( 'id' ).replace(/^add_/, ''); + name = id_to_windowname(name); + href = triggeringLink.attr( 'href' ); + + if (href.indexOf('?') === -1) { + href += '?'; + } + + href += '&winName=' + name; + + var height = 500; + var width = 800; + var left = (screen.width/2)-(width/2); + var top = (screen.height/2)-(height/2); + var win = window.open(href, name, 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=yes, resizable=yes, copyhistory=no, width='+width+', height='+height+', top='+top+', left='+left) + + function removeOverlay() { + if (win.closed) { + $('#yourlabs_overlay').remove(); + } else { + setTimeout(removeOverlay, 500); + } + } + + $('body').append('
    = 0; start--) { + if (value[start] === ',') { + break; + } + } + start = start < 0 ? 0 : start; + + // find end of word + for(var end=position; end <= value.length - 1; end++) { + if (value[end] === ',') { + break; + } + } + + while(value[start] === ',' || value[start] === ' ') start++; + while(value[end] === ',' || value[end] === ' ') end--; + + return [start, end + 1]; +} + +// TextWidget ties an input with an autocomplete. +yourlabs.TextWidget = function(input) { + this.input = input; + this.autocompleteOptions = { + getQuery: function() { + return this.input.getCursorWord(); + } + } +} + +// The widget is in charge of managing its Autocomplete. +yourlabs.TextWidget.prototype.initializeAutocomplete = function() { + this.autocomplete = this.input.yourlabsAutocomplete( + this.autocompleteOptions); + + // Add a class to ease css selection of autocompletes for widgets + this.autocomplete.box.addClass( + 'autocomplete-light-text-widget'); +}; + +// Bind Autocomplete.selectChoice signal to TextWidget.selectChoice() +yourlabs.TextWidget.prototype.bindSelectChoice = function() { + this.input.bind('selectChoice', function(e, choice) { + if (!choice.length) + return // placeholder: create choice here + + $(this).yourlabsTextWidget().selectChoice(choice); + }); +}; + +// Called when a choice is selected from the Autocomplete. +yourlabs.TextWidget.prototype.selectChoice = function(choice) { + var inputValue = this.input.val(); + var choiceValue = this.getValue(choice); + var positions = this.input.getCursorWordPositions(); + + var newValue = inputValue.substring(0, positions[0]); + newValue += choiceValue; + newValue += inputValue.substring(positions[1]); + + this.input.val(newValue); + this.input.focus(); +} + +// Return the value of an HTML choice, used to fill the input. +yourlabs.TextWidget.prototype.getValue = function(choice) { + return $.trim(choice.text()); +} + +// Initialize the widget. +yourlabs.TextWidget.prototype.initialize = function() { + this.initializeAutocomplete(); + this.bindSelectChoice(); +} + +// Destroy the widget. Takes a widget element because a cloned widget element +// will be dirty, ie. have wrong .input and .widget properties. +yourlabs.TextWidget.prototype.destroy = function(input) { + input + .unbind('selectChoice') + .yourlabsAutocomplete('destroy'); +} + +// TextWidget factory, registry and destroyer, as jQuery extension. +$.fn.yourlabsTextWidget = function(overrides) { + var widget; + overrides = overrides ? overrides : {}; + + if (overrides === 'destroy') { + widget = this.data('widget'); + if (widget) { + widget.destroy(this); + this.removeData('widget'); + } + return + } + + if (this.data('widget') === undefined) { + // Instanciate the widget + widget = new yourlabs.TextWidget(this); + + // Pares data-* + var data = this.data(); + var dataOverrides = { + autocompleteOptions: { + // workaround a display bug + minimumCharacters: 0, + getQuery: function() { + // Override getQuery since we need the autocomplete to filter + // choices based on the word the cursor is on, rather than the full + // input value. + return this.input.getCursorWord(); + } + } + }; + for (var key in data) { + if (!key) continue; + + if (key.substr(0, 12) === 'autocomplete') { + if (key === 'autocomplete') continue; + + var newKey = key.replace('autocomplete', ''); + newKey = newKey.replace(newKey[0], newKey[0].toLowerCase()) + dataOverrides.autocompleteOptions[newKey] = data[key]; + } else { + dataOverrides[key] = data[key]; + } + } + + // Allow attribute overrides + widget = $.extend(widget, dataOverrides); + + // Allow javascript object overrides + widget = $.extend(widget, overrides); + + this.data('widget', widget); + + // Setup for usage + widget.initialize(); + + // Widget is ready + widget.input.attr('data-widget-ready', 1); + widget.input.trigger('widget-ready'); + } + + return this.data('widget'); +} + +$(document).ready(function() { + $('body').on('initialize', 'input[data-widget-bootstrap=text]', function() { + /* + Only setup autocompletes on inputs which have + data-widget-bootstrap=text, if you want to initialize some + autocompletes with custom code, then set + data-widget-boostrap=yourbootstrap or something like that. + */ + $(this).yourlabsTextWidget(); + }); + + // Solid initialization, usage:: + // + // $(document).bind('yourlabsTextWidgetReady', function() { + // $('body').on('initialize', 'input[data-widget-bootstrap=text]', function() { + // $(this).yourlabsTextWidget({ + // yourCustomArgs: // ... + // }) + // }); + // }); + $(document).trigger('yourlabsTextWidgetReady'); + + $('.autocomplete-light-text-widget:not([id*="__prefix__"])').each(function() { + $(this).trigger('initialize'); + }); + + $(document).bind('DOMNodeInserted', function(e) { + var widget = $(e.target).find('.autocomplete-light-text-widget'); + + if (!widget.length) { + widget = $(e.target).is('.autocomplete-light-text-widget') ? $(e.target) : false; + + if (!widget) { + return; + } + } + + // Ignore inserted autocomplete box elements. + if (widget.is('.yourlabs-autocomplete')) { + return; + } + + // Ensure that the newly added widget is clean, in case it was cloned. + widget.yourlabsWidget('destroy'); + widget.find('input').yourlabsAutocomplete('destroy'); + + widget.trigger('initialize'); + }); +}) + +/* +Widget complements Autocomplete by enabling autocompletes to be used as +value holders. It looks very much like Autocomplete in its design. Thus, it +is recommended to read the source of Autocomplete first. + +Widget behaves like the autocomplete in facebook profile page, which all +users should be able to use. + +Behind the scenes, Widget maintains a normal hidden select which makes it +simple to play with on the server side like on the client side. If a value +is added and selected in the select element, then it is added to the deck, +and vice-versa. + +It needs some elements, and established vocabulary: + +- ".autocomplete-light-widget" element wraps all the HTML necessary for the + widget, +- ".deck" contains the list of selected choice(s) as HTML, +- "input" should be the text input that has the Autocomplete, +- "select" a (optionnaly multiple) select +- ".remove" a (preferabely hidden) element that contains a value removal + indicator, like an "X" sign or a trashcan icon, it is used to prefix every + children of the deck +- ".choice-template" a (preferabely hidden) element that contains the template + for choices which are added directly in the select, as they should be + copied in the deck, + +To avoid complexity, this script relies on extra HTML attributes, and +particularely one called 'data-value'. Learn more about data attributes: +http://dev.w3.org/html5/spec/global-attributes.html#embedding-custom-non-visible-data-with-the-data-attributes + +When a choice is selected from the Autocomplete, its element is cloned +and appended to the deck - "deck" contains "choices". It is important that the +choice elements of the autocomplete all contain a data-value attribute. +The value of data-value is used to fill the selected options in the hidden +select field. + +If choices may not all have a data-value attribute, then you can +override Widget.getValue() to implement your own logic. +*/ + +// Our class will live in the yourlabs global namespace. +if (window.yourlabs === undefined) window.yourlabs = {}; + +/* +Instanciate a Widget. +*/ +yourlabs.Widget = function(widget) { + // These attributes where described above. + this.widget = widget; + this.input = this.widget.find('input[data-autocomplete-url]'); + this.select = this.widget.find('select'); + this.deck = this.widget.find('.deck'); + this.choiceTemplate = this.widget.find('.choice-template .choice'); + + // The number of choices that the user may select with this widget. Set 0 + // for no limit. In the case of a foreign key you want to set it to 1. + this.maximumValues = 0; + + // Clear input when choice made? 1 for yes, 0 for no + this.clearInputOnSelectChoice = '1'; +} + +// When a choice is selected from the autocomplete of this widget, +// getValue() is called to add and select the option in the select. +yourlabs.Widget.prototype.getValue = function(choice) { + return choice.attr('data-value'); +}; + +// The widget is in charge of managing its Autocomplete. +yourlabs.Widget.prototype.initializeAutocomplete = function() { + this.autocomplete = this.input.yourlabsAutocomplete() + + // Add a class to ease css selection of autocompletes for widgets + this.autocomplete.box.addClass('autocomplete-light-widget'); +}; + +// Bind Autocomplete.selectChoice signal to Widget.selectChoice() +yourlabs.Widget.prototype.bindSelectChoice = function() { + this.input.bind('selectChoice', function(e, choice) { + if (!choice.length) + return // placeholder: create choice here + + var widget = $(this).parents('.autocomplete-light-widget' + ).yourlabsWidget(); + + widget.selectChoice(choice); + + widget.widget.trigger('widgetSelectChoice', [choice, widget]); + }); +}; + +// Called when a choice is selected from the Autocomplete. +yourlabs.Widget.prototype.selectChoice = function(choice) { + // Get the value for this choice. + var value = this.getValue(choice); + + if (!value) { + if (window.console) console.log('yourlabs.Widget.getValue failed'); + return; + } + + this.freeDeck(); + this.addToDeck(choice, value); + this.addToSelect(choice, value); + + var index = $(':input:visible').index(this.input); + this.resetDisplay(); + + if (this.clearInputOnSelectChoice === '1') { + this.input.val(''); + this.autocomplete.value = ''; + } + + if (this.input.is(':visible')) { + this.input.focus(); + } else { + var next = $(':input:visible:eq('+ index +')'); + next.focus(); + } + + if (! this.select.is('[multiple]')) { + this.input.prop('disabled', true); + } +} + +// Unselect a value if the maximum number of selected values has been +// reached. +yourlabs.Widget.prototype.freeDeck = function() { + var slots = this.maximumValues - this.deck.children().length; + + if (this.maximumValues && slots < 1) { + // We'll remove the first choice which is supposed to be the oldest + var choice = $(this.deck.children()[0]); + + this.deselectChoice(choice); + } +} + +// Empty the search input and hide it if maximumValues has been reached. +yourlabs.Widget.prototype.resetDisplay = function() { + var selected = this.select.find('option:selected').length; + + if (this.maximumValues && selected === this.maximumValues) { + this.input.hide(); + } else { + this.input.show(); + } + + this.deck.show(); + + // Also fix the position if the autocomplete is shown. + if (this.autocomplete.box.is(':visible')) this.autocomplete.fixPosition(); +} + +yourlabs.Widget.prototype.deckChoiceHtml = function(choice, value) { + var deckChoice = choice.clone(); + + this.addRemove(deckChoice); + + return deckChoice; +} + +yourlabs.Widget.prototype.optionChoice = function(option) { + var optionChoice = this.choiceTemplate.clone(); + + var target = optionChoice.find('.append-option-html'); + + if (target.length) { + target.append(option.html()); + } else { + optionChoice.html(option.html()); + } + + return optionChoice; +} + +yourlabs.Widget.prototype.addRemove = function(choices) { + var removeTemplate = this.widget.find('.remove:last') + .clone().css('display', 'inline-block'); + + var target = choices.find('.prepend-remove'); + + if (target.length) { + target.prepend(removeTemplate); + } else { + // Add the remove icon to each choice + choices.prepend(removeTemplate); + } +} + +// Add a selected choice of a given value to the deck. +yourlabs.Widget.prototype.addToDeck = function(choice, value) { + var existing_choice = this.deck.find('[data-value="'+value+'"]'); + + // Avoid duplicating choices in the deck. + if (!existing_choice.length) { + var deckChoice = this.deckChoiceHtml(choice); + + // In case getValue() actually **created** the value, for example + // with a post request. + deckChoice.attr('data-value', value); + + this.deck.append(deckChoice); + } +} + +// Add a selected choice of a given value to the deck. +yourlabs.Widget.prototype.addToSelect = function(choice, value) { + var option = this.select.find('option[value="'+value+'"]'); + + if (! option.length) { + this.select.append( + ''); + option = this.select.find('option[value="'+value+'"]'); + } + + option.attr('selected', 'selected'); + + this.select.trigger('change'); + this.updateAutocompleteExclude(); +} + +// Called when the user clicks .remove in a deck choice. +yourlabs.Widget.prototype.deselectChoice = function(choice) { + var value = this.getValue(choice); + + this.select.find('option[value="'+value+'"]').remove(); + this.select.trigger('change'); + + choice.remove(); + + if (this.deck.children().length === 0) { + this.deck.hide(); + } + + this.updateAutocompleteExclude(); + this.resetDisplay(); + + this.input.prop('disabled', false); + + this.widget.trigger('widgetDeselectChoice', [choice, this]); +}; + +yourlabs.Widget.prototype.updateAutocompleteExclude = function() { + var widget = this; + var choices = this.deck.find(this.autocomplete.choiceSelector); + + this.autocomplete.data.exclude = $.map(choices, function(choice) { + return widget.getValue($(choice)); + }); +} + +yourlabs.Widget.prototype.initialize = function() { + this.initializeAutocomplete(); + + // Working around firefox tempering form values after reload + var widget = this; + this.deck.find(this.autocomplete.choiceSelector).each(function() { + var value = widget.getValue($(this)); + var option = widget.select.find('option[value="'+value+'"]'); + if (!option.prop('selected')) option.prop('selected', true); + }); + + var choices = this.deck.find( + this.input.yourlabsAutocomplete().choiceSelector); + + this.addRemove(choices); + this.resetDisplay(); + + if (widget.select.val() && ! this.select.is('[multiple]')) { + this.input.prop('disabled', true); + } + + this.bindSelectChoice(); +} + +// Destroy the widget. Takes a widget element because a cloned widget element +// will be dirty, ie. have wrong .input and .widget properties. +yourlabs.Widget.prototype.destroy = function(widget) { + widget.find('input') + .unbind('selectChoice') + .yourlabsAutocomplete('destroy'); +} + +// Get or create or destroy a widget instance. +// +// On first call, yourlabsWidget() will instanciate a widget applying all +// passed overrides. +// +// On later calls, yourlabsWidget() will return the previously created widget +// instance, which is stored in widget.data('widget'). +// +// Calling yourlabsWidget('destroy') will destroy the widget. Useful if the +// element was blindly cloned with .clone(true) for example. +$.fn.yourlabsWidget = function(overrides) { + overrides = overrides ? overrides : {}; + + var widget = this.yourlabsRegistry('widget'); + + if (overrides === 'destroy') { + if (widget) { + widget.destroy(this); + this.removeData('widget'); + } + return + } + + if (widget === undefined) { + // Instanciate the widget + widget = new yourlabs.Widget(this); + + // Extend the instance with data-widget-* overrides + for (var key in this.data()) { + if (!key) continue; + if (key.substr(0, 6) !== 'widget' || key === 'widget') continue; + var newKey = key.replace('widget', ''); + newKey = newKey.charAt(0).toLowerCase() + newKey.slice(1); + widget[newKey] = this.data(key); + } + + // Allow javascript object overrides + widget = $.extend(widget, overrides); + + $(this).yourlabsRegistry('widget', widget); + + // Setup for usage + widget.initialize(); + + // Widget is ready + widget.widget.attr('data-widget-ready', 1); + widget.widget.trigger('widget-ready'); + } + + return widget; +} + +$(document).ready(function() { + $('body').on('initialize', '.autocomplete-light-widget[data-widget-bootstrap=normal]', function() { + /* + Only setup widgets which have data-widget-bootstrap=normal, if you want to + initialize some Widgets with custom code, then set + data-widget-boostrap=yourbootstrap or something like that. + */ + $(this).yourlabsWidget(); + }); + + // Call Widget.deselectChoice when .remove is clicked + $('body').on('click', '.autocomplete-light-widget .deck .remove', function() { + var widget = $(this).parents('.autocomplete-light-widget' + ).yourlabsWidget(); + + var selector = widget.input.yourlabsAutocomplete().choiceSelector; + var choice = $(this).parents(selector); + + widget.deselectChoice(choice); + }); + + // Solid initialization, usage: + // + // + // $(document).bind('yourlabsWidgetReady', function() { + // $('.your.autocomplete-light-widget').on('initialize', function() { + // $(this).yourlabsWidget({ + // yourCustomArgs: // ... + // }) + // }); + // }); + $(document).trigger('yourlabsWidgetReady'); + + $('.autocomplete-light-widget:not([id*="__prefix__"])').each(function() { + $(this).trigger('initialize'); + }); + + $(document).bind('DOMNodeInserted', function(e) { + /* + Support values added directly in the select via js (ie. choices created in + modal or popup). + + For this, we listen to DOMNodeInserted and intercept insert of ') + */ + var widget; + + if ($(e.target).is('option')) { // added an option ? + widget = $(e.target).parents('.autocomplete-light-widget'); + + if (!widget.length) { + return; + } + + widget = widget.yourlabsWidget(); + var option = $(e.target); + var value = option.attr('value'); + var choice = widget.deck.find('[data-value="'+value+'"]'); + + if (!choice.length) { + var deckChoice = widget.optionChoice(option); + + deckChoice.attr('data-value', value); + + widget.selectChoice(deckChoice); + } + } else { // added a widget ? + var notReady = '.autocomplete-light-widget:not([data-widget-ready])' + widget = $(e.target).find(notReady); + + if (!widget.length) { + return; + } + + // Ignore inserted autocomplete box elements. + if (widget.is('.yourlabs-autocomplete')) { + return; + } + + // Ensure that the newly added widget is clean, in case it was + // cloned with data. + widget.yourlabsWidget('destroy'); + widget.find('input').yourlabsAutocomplete('destroy'); + + // added a widget: initialize the widget. + widget.trigger('initialize'); + } + }); + + var ie = yourlabs.getInternetExplorerVersion(); + if (ie !== -1 && ie < 9) { + observe = [ + '.autocomplete-light-widget:not([data-yourlabs-skip])', + '.autocomplete-light-widget option:not([data-yourlabs-skip])' + ].join(); + $(observe).attr('data-yourlabs-skip', 1); + + var ieDOMNodeInserted = function() { + // http://msdn.microsoft.com/en-us/library/ms536957 + $(observe).each(function() { + $(document).trigger(jQuery.Event('DOMNodeInserted', {target: $(this)})); + $(this).attr('data-yourlabs-skip', 1); + }); + + setTimeout(ieDOMNodeInserted, 500); + } + setTimeout(ieDOMNodeInserted, 500); + } + +}); diff --git a/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js b/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js new file mode 100644 index 00000000..a5bc6774 --- /dev/null +++ b/shared/static/vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js @@ -0,0 +1,9 @@ +/* + * jquery-autocomplete-light - v3.5.0 + * Dead simple autocompletion and widgets for jQuery + * http://yourlabs.org + * + * Made by James Pic + * Under MIT License + */ +if(void 0===window.isOpera)var isOpera=navigator.userAgent.indexOf("Opera")>=0&&parseFloat(navigator.appVersion);if(void 0===window.isIE)var isIE=document.all&&!isOpera&&parseFloat(navigator.appVersion.split("MSIE ")[1].split(";")[0]);void 0===window.findPosX&&(window.findPosX=function(a){var b=0;if(a.offsetParent){for(;a.offsetParent;)b+=a.offsetLeft-(isOpera?0:a.scrollLeft),a=a.offsetParent;isIE&&a.parentElement&&(b+=a.offsetLeft-a.scrollLeft)}else a.x&&(b+=a.x);return b}),void 0===window.findPosY&&(window.findPosY=function(a){var b=0;if(a.offsetParent){for(;a.offsetParent;)b+=a.offsetTop-(isOpera?0:a.scrollTop),a=a.offsetParent;isIE&&a.parentElement&&(b+=a.offsetTop-a.scrollTop)}else a.y&&(b+=a.y);return b}),void 0===window.yourlabs&&(window.yourlabs={}),void 0!==window.yourlabs.Autocomplete&&console.log("WARNING ! You are loading autocomplete.js **again**."),yourlabs.getInternetExplorerVersion=function(){var a=-1;if("Microsoft Internet Explorer"===navigator.appName){var b=navigator.userAgent;null!==new RegExp("MSIE ([0-9]{1,}[.0-9]{0,})").exec(b)&&(a=parseFloat(RegExp.$1))}return a},$.fn.yourlabsRegistry=function(a,b){var c=yourlabs.getInternetExplorerVersion();if(-1===c||c>8)return void 0===b?this.data(a):this.data(a,b);void 0===$.fn.yourlabsRegistry.data&&($.fn.yourlabsRegistry.data={}),void 0===$.fn.yourlabsRegistry.guid&&($.fn.yourlabsRegistry.guid=function(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(a){var b=16*Math.random()|0;return("x"===a?b:3&b|8).toString(16)})});var d="data-yourlabs-"+a+"-registry-id",e=this.attr(d);return void 0===e&&(e=$.fn.yourlabsRegistry.guid(),this.attr(d,e)),void 0!==b&&($.fn.yourlabsRegistry.data[e]=b),$.fn.yourlabsRegistry.data[e]},yourlabs.Autocomplete=function(a){this.input=a,this.value=null,this.minimumCharacters=2,this.hideAfter=200,this.alignRight=!1,this.url=!1,this.xhrWait=200,this.choiceSelector=".choice",this.hilightClass="hilight",this.autoHilightFirst=!1,this.bindMouseDown=!0,this.queryVariable="q",this.data={},this.xhr=!1,this.lastData={},this.box=$(''),this.container=this.input.parents().filter(function(){var a=$(this).css("position");return"absolute"===a||"fixed"===a}).first(),this.container.length||(this.container=$("body"))},yourlabs.Autocomplete.prototype.initialize=function(){var a=yourlabs.getInternetExplorerVersion();if(this.input.on("blur.autocomplete",$.proxy(this.inputBlur,this)).on("focus.autocomplete",$.proxy(this.inputClick,this)).on("keydown.autocomplete",$.proxy(this.inputKeyup,this)),$(window).on("resize",$.proxy(function(){this.box.is(":visible")&&this.fixPosition()},this)),/Firefox/i.test(navigator.userAgent)&&$(window).on("scroll",$.proxy(this.hide,this)),-1===a||a>9)this.input.on("input.autocomplete",$.proxy(this.refresh,this));else{var b=["keyup.autocomplete","keypress.autocomplete","cut.autocomplete","paste.autocomplete"];this.input.on(b.join(" "),function(a){$.proxy(this.inputKeyup,this)})}this.box.on("mouseenter",this.choiceSelector,$.proxy(this.boxMouseenter,this)).on("mouseleave",this.choiceSelector,$.proxy(this.boxMouseleave,this)),this.bindMouseDown&&this.box.on("mousedown",this.choiceSelector,$.proxy(this.boxClick,this)),this.data[this.queryVariable]=""},yourlabs.Autocomplete.prototype.destroy=function(a){a.unbind("blur.autocomplete").unbind("focus.autocomplete").unbind("input.autocomplete").unbind("keydown.autocomplete").unbind("keypress.autocomplete").unbind("keyup.autocomplete")},yourlabs.Autocomplete.prototype.inputBlur=function(a){window.setTimeout($.proxy(this.hide,this),this.hideAfter)},yourlabs.Autocomplete.prototype.inputClick=function(a){null===this.value&&(this.value=this.getQuery()),this.value.length>=this.minimumCharacters&&this.show()},yourlabs.Autocomplete.prototype.boxMouseenter=function(a){var b=this.box.find("."+this.hilightClass);this.input.trigger("dehilightChoice",[b,this]),this.input.trigger("hilightChoice",[$(a.currentTarget),this])},yourlabs.Autocomplete.prototype.boxMouseleave=function(a){this.input.trigger("dehilightChoice",[this.box.find("."+this.hilightClass),this])},yourlabs.Autocomplete.prototype.boxClick=function(a){var b=this.box.find("."+this.hilightClass);this.input.trigger("selectChoice",[b,this])},yourlabs.Autocomplete.prototype.getQuery=function(){return this.input.val()},yourlabs.Autocomplete.prototype.inputKeyup=function(a){if(this.input.is(":visible"))switch(a.keyCode){case 40:case 38:case 16:case 17:case 18:this.move(a);break;case 9:case 13:if(!this.box.is(":visible"))return;var b=this.box.find("."+this.hilightClass);if(!b.length)return;a.preventDefault(),a.stopPropagation(),this.input.trigger("selectChoice",[b,this]);break;case 27:if(!this.box.is(":visible"))return;this.hide();break;default:this.refresh()}},yourlabs.Autocomplete.prototype.show=function(a){this.fixPosition();var b=0===$.trim(this.box.find(this.choiceSelector)).length;if((this.hasChanged()||b)&&!this.xhr)return void this.fetch();if(void 0!==a&&(this.box.html(a),this.fixPosition()),this.box.is(":empty"))return void(this.box.is(":visible")&&this.hide());var c=this.box.find("."+this.hilightClass),d=this.box.find(this.choiceSelector+":first");d&&!c.length&&this.autoHilightFirst&&d.addClass(this.hilightClass),this.box.is(":visible")||(this.box.css("display","block"),this.fixPosition())},yourlabs.Autocomplete.prototype.hide=function(){this.box.hide()},yourlabs.Autocomplete.prototype.move=function(a){if(null===this.value&&(this.value=this.getQuery()),this.value.length-1&&b.length&&a.preventDefault();var c;if(38!==a.keyCode||a.charCode){if(40!==a.keyCode||a.charCode)return;c="down"}else c="up";var d,e=this.box.find(this.choiceSelector+":first"),f=this.box.find(this.choiceSelector+":last");this.show(),b.length?("up"===c?(d=b.prevAll(this.choiceSelector+":first"),d.length||(d=f)):(d=b.nextAll(this.choiceSelector+":first"),d.length||(d=e)),this.input.trigger("dehilightChoice",[b,this])):d="up"===c?f:e,a.preventDefault(),this.input.trigger("hilightChoice",[d,this])},yourlabs.Autocomplete.prototype.fixPosition=function(){var a=this.input.get(0),b=this.input.parents().filter(function(){return"auto"!==$(this).css("z-index")&&"0"!==$(this).css("z-index")}).first().css("z-index"),c=this.input.parents().filter(function(){return"absolute"===$(this).css("position")}).get(0),d=findPosY(a)+this.input.outerHeight()+"px",e=findPosX(a)+"px";if(void 0!==c){var f=findPosY(c),g=findPosX(c),h=findPosY(a)+this.input.outerHeight(),i=findPosX(a);d=h-f+"px",e=i-g+"px"}this.alignRight&&(e=findPosX(a)+a.scrollLeft-(this.box.outerWidth()-this.input.outerWidth())+"px"),this.box.appendTo(this.container).css({position:"absolute",minWidth:parseInt(this.input.outerWidth()),top:d,left:e,zIndex:b})},yourlabs.Autocomplete.prototype.refresh=function(){this.value=this.getQuery(),this.value.length=0&&","!==b[c];c--);c=c<0?0:c;for(var d=a;d<=b.length-1&&","!==b[d];d++);for(;","===b[c]||" "===b[c];)c++;for(;","===b[d]||" "===b[d];)d--;return[c,d+1]},yourlabs.TextWidget=function(a){this.input=a,this.autocompleteOptions={getQuery:function(){return this.input.getCursorWord()}}},yourlabs.TextWidget.prototype.initializeAutocomplete=function(){this.autocomplete=this.input.yourlabsAutocomplete(this.autocompleteOptions),this.autocomplete.box.addClass("autocomplete-light-text-widget")},yourlabs.TextWidget.prototype.bindSelectChoice=function(){this.input.bind("selectChoice",function(a,b){b.length&&$(this).yourlabsTextWidget().selectChoice(b)})},yourlabs.TextWidget.prototype.selectChoice=function(a){var b=this.input.val(),c=this.getValue(a),d=this.input.getCursorWordPositions(),e=b.substring(0,d[0]);e+=c,e+=b.substring(d[1]),this.input.val(e),this.input.focus()},yourlabs.TextWidget.prototype.getValue=function(a){return $.trim(a.text())},yourlabs.TextWidget.prototype.initialize=function(){this.initializeAutocomplete(),this.bindSelectChoice()},yourlabs.TextWidget.prototype.destroy=function(a){a.unbind("selectChoice").yourlabsAutocomplete("destroy")},$.fn.yourlabsTextWidget=function(a){var b;if("destroy"===(a=a||{}))return void((b=this.data("widget"))&&(b.destroy(this),this.removeData("widget")));if(void 0===this.data("widget")){b=new yourlabs.TextWidget(this);var c=this.data(),d={autocompleteOptions:{minimumCharacters:0,getQuery:function(){return this.input.getCursorWord()}}};for(var e in c)if(e)if("autocomplete"===e.substr(0,12)){if("autocomplete"===e)continue;var f=e.replace("autocomplete","");f=f.replace(f[0],f[0].toLowerCase()),d.autocompleteOptions[f]=c[e]}else d[e]=c[e];b=$.extend(b,d),b=$.extend(b,a),this.data("widget",b),b.initialize(),b.input.attr("data-widget-ready",1),b.input.trigger("widget-ready")}return this.data("widget")},$(document).ready(function(){$("body").on("initialize","input[data-widget-bootstrap=text]",function(){$(this).yourlabsTextWidget()}),$(document).trigger("yourlabsTextWidgetReady"),$('.autocomplete-light-text-widget:not([id*="__prefix__"])').each(function(){$(this).trigger("initialize")}),$(document).bind("DOMNodeInserted",function(a){var b=$(a.target).find(".autocomplete-light-text-widget");(b.length||(b=!!$(a.target).is(".autocomplete-light-text-widget")&&$(a.target)))&&(b.is(".yourlabs-autocomplete")||(b.yourlabsWidget("destroy"),b.find("input").yourlabsAutocomplete("destroy"),b.trigger("initialize")))})}),void 0===window.yourlabs&&(window.yourlabs={}),yourlabs.Widget=function(a){this.widget=a,this.input=this.widget.find("input[data-autocomplete-url]"),this.select=this.widget.find("select"),this.deck=this.widget.find(".deck"),this.choiceTemplate=this.widget.find(".choice-template .choice"),this.maximumValues=0,this.clearInputOnSelectChoice="1"},yourlabs.Widget.prototype.getValue=function(a){return a.attr("data-value")},yourlabs.Widget.prototype.initializeAutocomplete=function(){this.autocomplete=this.input.yourlabsAutocomplete(),this.autocomplete.box.addClass("autocomplete-light-widget")},yourlabs.Widget.prototype.bindSelectChoice=function(){this.input.bind("selectChoice",function(a,b){if(b.length){var c=$(this).parents(".autocomplete-light-widget").yourlabsWidget();c.selectChoice(b),c.widget.trigger("widgetSelectChoice",[b,c])}})},yourlabs.Widget.prototype.selectChoice=function(a){var b=this.getValue(a);if(!b)return void(window.console&&console.log("yourlabs.Widget.getValue failed"));this.freeDeck(),this.addToDeck(a,b),this.addToSelect(a,b);var c=$(":input:visible").index(this.input);if(this.resetDisplay(),"1"===this.clearInputOnSelectChoice&&(this.input.val(""),this.autocomplete.value=""),this.input.is(":visible"))this.input.focus();else{$(":input:visible:eq("+c+")").focus()}this.select.is("[multiple]")||this.input.prop("disabled",!0)},yourlabs.Widget.prototype.freeDeck=function(){var a=this.maximumValues-this.deck.children().length;if(this.maximumValues&&a<1){var b=$(this.deck.children()[0]);this.deselectChoice(b)}},yourlabs.Widget.prototype.resetDisplay=function(){var a=this.select.find("option:selected").length;this.maximumValues&&a===this.maximumValues?this.input.hide():this.input.show(),this.deck.show(),this.autocomplete.box.is(":visible")&&this.autocomplete.fixPosition()},yourlabs.Widget.prototype.deckChoiceHtml=function(a,b){var c=a.clone();return this.addRemove(c),c},yourlabs.Widget.prototype.optionChoice=function(a){var b=this.choiceTemplate.clone(),c=b.find(".append-option-html");return c.length?c.append(a.html()):b.html(a.html()),b},yourlabs.Widget.prototype.addRemove=function(a){var b=this.widget.find(".remove:last").clone().css("display","inline-block"),c=a.find(".prepend-remove");c.length?c.prepend(b):a.prepend(b)},yourlabs.Widget.prototype.addToDeck=function(a,b){if(!this.deck.find('[data-value="'+b+'"]').length){var c=this.deckChoiceHtml(a);c.attr("data-value",b),this.deck.append(c)}},yourlabs.Widget.prototype.addToSelect=function(a,b){var c=this.select.find('option[value="'+b+'"]');c.length||(this.select.append(''),c=this.select.find('option[value="'+b+'"]')),c.attr("selected","selected"),this.select.trigger("change"),this.updateAutocompleteExclude()},yourlabs.Widget.prototype.deselectChoice=function(a){var b=this.getValue(a);this.select.find('option[value="'+b+'"]').remove(),this.select.trigger("change"),a.remove(),0===this.deck.children().length&&this.deck.hide(),this.updateAutocompleteExclude(),this.resetDisplay(),this.input.prop("disabled",!1),this.widget.trigger("widgetDeselectChoice",[a,this])},yourlabs.Widget.prototype.updateAutocompleteExclude=function(){var a=this,b=this.deck.find(this.autocomplete.choiceSelector);this.autocomplete.data.exclude=$.map(b,function(b){return a.getValue($(b))})},yourlabs.Widget.prototype.initialize=function(){this.initializeAutocomplete();var a=this;this.deck.find(this.autocomplete.choiceSelector).each(function(){var b=a.getValue($(this)),c=a.select.find('option[value="'+b+'"]');c.prop("selected")||c.prop("selected",!0)});var b=this.deck.find(this.input.yourlabsAutocomplete().choiceSelector);this.addRemove(b),this.resetDisplay(),a.select.val()&&!this.select.is("[multiple]")&&this.input.prop("disabled",!0),this.bindSelectChoice()},yourlabs.Widget.prototype.destroy=function(a){a.find("input").unbind("selectChoice").yourlabsAutocomplete("destroy")},$.fn.yourlabsWidget=function(a){a=a||{};var b=this.yourlabsRegistry("widget");if("destroy"===a)return void(b&&(b.destroy(this),this.removeData("widget")));if(void 0===b){b=new yourlabs.Widget(this);for(var c in this.data())if(c&&"widget"===c.substr(0,6)&&"widget"!==c){var d=c.replace("widget","");d=d.charAt(0).toLowerCase()+d.slice(1),b[d]=this.data(c)}b=$.extend(b,a),$(this).yourlabsRegistry("widget",b),b.initialize(),b.widget.attr("data-widget-ready",1),b.widget.trigger("widget-ready")}return b},$(document).ready(function(){$("body").on("initialize",".autocomplete-light-widget[data-widget-bootstrap=normal]",function(){$(this).yourlabsWidget()}),$("body").on("click",".autocomplete-light-widget .deck .remove",function(){var a=$(this).parents(".autocomplete-light-widget").yourlabsWidget(),b=a.input.yourlabsAutocomplete().choiceSelector,c=$(this).parents(b);a.deselectChoice(c)}),$(document).trigger("yourlabsWidgetReady"),$('.autocomplete-light-widget:not([id*="__prefix__"])').each(function(){$(this).trigger("initialize")}),$(document).bind("DOMNodeInserted",function(a){var b;if($(a.target).is("option")){if(b=$(a.target).parents(".autocomplete-light-widget"),!b.length)return;b=b.yourlabsWidget();var c=$(a.target),d=c.attr("value");if(!b.deck.find('[data-value="'+d+'"]').length){var e=b.optionChoice(c);e.attr("data-value",d),b.selectChoice(e)}}else{if(b=$(a.target).find(".autocomplete-light-widget:not([data-widget-ready])"),!b.length)return;if(b.is(".yourlabs-autocomplete"))return;b.yourlabsWidget("destroy"),b.find("input").yourlabsAutocomplete("destroy"),b.trigger("initialize")}});var a=yourlabs.getInternetExplorerVersion();if(-1!==a&&a<9){observe=[".autocomplete-light-widget:not([data-yourlabs-skip])",".autocomplete-light-widget option:not([data-yourlabs-skip])"].join(),$(observe).attr("data-yourlabs-skip",1);var b=function(){$(observe).each(function(){$(document).trigger(jQuery.Event("DOMNodeInserted",{target:$(this)})),$(this).attr("data-yourlabs-skip",1)}),setTimeout(b,500)};setTimeout(b,500)}}); \ No newline at end of file From a5071aa2576ae7117f78290656e32a4fa4d53619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 20 Jan 2018 22:24:13 +0100 Subject: [PATCH 37/84] cof -- Add tests for club views --- gestioncof/tests/test_views.py | 122 +++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..738fe353 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,122 @@ +from django.test import Client, TestCase +from django.urls import reverse + +from gestioncof.models import Club +from gestioncof.tests.testcases import ViewTestCaseMixin + +from .utils import create_user + + +class ClubListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'liste-clubs' + url_expected = '/clubs/liste' + + auth_user = 'member' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + + self.c1 = Club.objects.create(name='Club1') + self.c2 = Club.objects.create(name='Club2') + + m = self.users['member'] + self.c1.membres.add(m) + self.c1.respos.add(m) + + def test_as_member(self): + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertEqual(r.context['owned_clubs'].get(), self.c1) + self.assertEqual(r.context['other_clubs'].get(), self.c2) + + def test_as_staff(self): + u = self.users['staff'] + c = Client() + c.force_login(u) + + r = c.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['owned_clubs'], map(repr, [self.c1, self.c2]), + ordered=False, + ) + + +class ClubMembersViewTests(ViewTestCaseMixin, TestCase): + url_name = 'membres-club' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + @property + def url_kwargs(self): + return {'name': self.c.name} + + @property + def url_expected(self): + return '/clubs/membres/{}'.format(self.c.name) + + def setUp(self): + super().setUp() + + self.u1 = create_user('u1') + self.u2 = create_user('u2') + + self.c = Club.objects.create(name='Club') + self.c.membres.add(self.u1, self.u2) + self.c.respos.add(self.u1) + + def test_as_staff(self): + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertEqual(r.context['members_no_respo'].get(), self.u2) + + def test_as_respo(self): + u = self.users['user'] + self.c.respos.add(u) + + c = Client() + c.force_login(u) + r = c.get(self.url) + + self.assertEqual(r.status_code, 200) + + +class ClubChangeRespoViewTests(ViewTestCaseMixin, TestCase): + url_name = 'change-respo' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + @property + def url_kwargs(self): + return {'club_name': self.c.name, 'user_id': self.users['user'].pk} + + @property + def url_expected(self): + return '/clubs/change_respo/{}/{}'.format( + self.c.name, self.users['user'].pk, + ) + + def setUp(self): + super().setUp() + + self.c = Club.objects.create(name='Club') + + def test(self): + u = self.users['user'] + expected_redirect = reverse('membres-club', kwargs={ + 'name': self.c.name, + }) + self.c.membres.add(u) + + r = self.client.get(self.url) + self.assertRedirects(r, expected_redirect) + self.assertIn(u, self.c.respos.all()) + + self.client.get(self.url) + self.assertNotIn(u, self.c.respos.all()) From bbe46645f75b123989f71c07054091c9df9e3a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 20 Jan 2018 22:24:25 +0100 Subject: [PATCH 38/84] cof -- Fix the club list view --- gestioncof/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gestioncof/views.py b/gestioncof/views.py index 5dfee83f..4e0cb080 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -566,7 +566,7 @@ def liste_clubs(request): if request.user.profile.is_buro: data = {'owned_clubs': clubs.all()} else: - data = {'owned_clubs': request.user.clubs_geres, + data = {'owned_clubs': request.user.clubs_geres.all(), 'other_clubs': clubs.exclude(respos=request.user)} return render(request, 'liste_clubs.html', data) From 7e0ecd8e0f707f912b66cb438778048b877e203e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 21 Jan 2018 17:51:23 +0100 Subject: [PATCH 39/84] Add assertion to check ical data is as expected --- shared/tests/testcases.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/shared/tests/testcases.py b/shared/tests/testcases.py index 15792383..14a5fbfe 100644 --- a/shared/tests/testcases.py +++ b/shared/tests/testcases.py @@ -8,6 +8,8 @@ from django.test import Client from django.utils import timezone from django.utils.functional import cached_property +import icalendar + User = get_user_model() @@ -92,6 +94,40 @@ class TestCaseMixin: else: self.assertEqual(actual, expected) + def _test_event_equal(self, event, exp): + for k, v_desc in exp.items(): + if isinstance(v_desc, tuple): + v_getter = v_desc[0] + v = v_desc[1] + else: + v_getter = lambda v: v + v = v_desc + if v_getter(event[k.upper()]) != v: + return False + return True + + def _find_event(self, ev, l): + for i, elt in enumerate(l): + if self._test_event_equal(ev, elt): + return elt, i + return False, -1 + + def assertCalEqual(self, ical_content, expected): + remaining = expected.copy() + unexpected = [] + + cal = icalendar.Calendar.from_ical(ical_content) + + for ev in cal.walk('vevent'): + found, i_found = self._find_event(ev, remaining) + if found: + remaining.pop(i_found) + else: + unexpected.append(ev) + + self.assertListEqual(unexpected, []) + self.assertListEqual(remaining, []) + class ViewTestCaseMixin(TestCaseMixin): """ From 2e6a54c7dbb8cff5077d35230e063712aa90b3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 21 Jan 2018 17:52:53 +0100 Subject: [PATCH 40/84] cof -- Add tests for calendar views --- gestioncof/tests/test_views.py | 151 +++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..e33fe2f2 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,151 @@ +import uuid +from datetime import timedelta + +from django.contrib import messages +from django.contrib.messages.api import get_messages +from django.contrib.messages.storage.base import Message +from django.test import TestCase + +from bda.models import Salle, Tirage +from gestioncof.models import CalendarSubscription +from gestioncof.tests.testcases import ViewTestCaseMixin + + +class CalendarViewTests(ViewTestCaseMixin, TestCase): + url_name = 'calendar' + url_expected = '/calendar/subscription' + + auth_user = 'member' + auth_forbidden = [None, 'user'] + + post_expected_message = Message( + messages.SUCCESS, "Calendrier mis à jour avec succès.") + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_new(self): + r = self.client.post(self.url, { + 'subscribe_to_events': True, + 'subscribe_to_my_shows': True, + 'other_shows': [], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + cs = self.users['member'].calendarsubscription + self.assertTrue(cs.subscribe_to_events) + self.assertTrue(cs.subscribe_to_my_shows) + + def test_post_edit(self): + u = self.users['member'] + token = uuid.uuid4() + cs = CalendarSubscription.objects.create(token=token, user=u) + + r = self.client.post(self.url, { + 'other_shows': [], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + cs.refresh_from_db() + self.assertEqual(cs.token, token) + self.assertFalse(cs.subscribe_to_events) + self.assertFalse(cs.subscribe_to_my_shows) + + def test_post_other_shows(self): + t = Tirage.objects.create( + ouverture=self.now, + fermeture=self.now, + active=True, + ) + l = Salle.objects.create() + s = t.spectacle_set.create( + date=self.now, price=3.5, slots=20, location=l, listing=True) + + r = self.client.post(self.url, {'other_shows': [str(s.pk)]}) + + self.assertEqual(r.status_code, 200) + + +class CalendarICSViewTests(ViewTestCaseMixin, TestCase): + url_name = 'gestioncof.views.calendar_ics' + + auth_user = None + auth_forbidden = [] + + @property + def url_kwargs(self): + return {'token': self.token} + + @property + def url_expected(self): + return '/calendar/{}/calendar.ics'.format(self.token) + + def setUp(self): + super().setUp() + + self.token = uuid.uuid4() + + self.t = Tirage.objects.create( + ouverture=self.now, + fermeture=self.now, + active=True, + ) + l = Salle.objects.create(name='Location') + self.s1 = self.t.spectacle_set.create( + price=1, slots=10, location=l, listing=True, + title='Spectacle 1', date=self.now + timedelta(days=1), + ) + self.s2 = self.t.spectacle_set.create( + price=2, slots=20, location=l, listing=True, + title='Spectacle 2', date=self.now + timedelta(days=2), + ) + self.s3 = self.t.spectacle_set.create( + price=3, slots=30, location=l, listing=True, + title='Spectacle 3', date=self.now + timedelta(days=3), + ) + + def test(self): + u = self.users['user'] + p = u.participant_set.create(tirage=self.t) + p.attribution_set.create(spectacle=self.s1) + + self.cs = CalendarSubscription.objects.create( + user=u, token=self.token, + subscribe_to_my_shows=True, subscribe_to_events=True, + ) + self.cs.other_shows.add(self.s2) + + r = self.client.get(self.url) + + def get_dt_from_ical(v): + return v.dt + + self.assertCalEqual(r.content.decode('utf-8'), [ + { + 'summary': 'Spectacle 1', + 'dtstart': (get_dt_from_ical, ( + (self.now + timedelta(days=1)).replace(microsecond=0) + )), + 'dtend': (get_dt_from_ical, ( + (self.now + timedelta(days=1, hours=2)).replace( + microsecond=0) + )), + 'location': 'Location', + 'uid': 'show-{}-{}@example.com'.format(self.s1.pk, self.t.pk), + }, + { + 'summary': 'Spectacle 2', + 'dtstart': (get_dt_from_ical, ( + (self.now + timedelta(days=2)).replace(microsecond=0) + )), + 'dtend': (get_dt_from_ical, ( + (self.now + timedelta(days=2, hours=2)).replace( + microsecond=0) + )), + 'location': 'Location', + 'uid': 'show-{}-{}@example.com'.format(self.s2.pk, self.t.pk), + }, + ]) From acf284862a57f1857a2049067d4bb081a797fffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 21 Jan 2018 18:00:56 +0100 Subject: [PATCH 41/84] Users should be able to refuse to subscribe to shows and events --- gestioncof/forms.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gestioncof/forms.py b/gestioncof/forms.py index 2124b7c8..5a25b815 100644 --- a/gestioncof/forms.py +++ b/gestioncof/forms.py @@ -351,10 +351,12 @@ EventFormset = formset_factory(AdminEventForm, BaseEventRegistrationFormset) class CalendarForm(forms.ModelForm): subscribe_to_events = forms.BooleanField( initial=True, - label="Événements du COF") + label="Événements du COF", + required=False) subscribe_to_my_shows = forms.BooleanField( initial=True, - label="Les spectacles pour lesquels j'ai obtenu une place") + label="Les spectacles pour lesquels j'ai obtenu une place", + required=False) other_shows = forms.ModelMultipleChoiceField( label="Spectacles supplémentaires", queryset=Spectacle.objects.filter(tirage__active=True), From 38539a9d53607efd417bee8d4d1d930999e39d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sun, 21 Jan 2018 18:03:01 +0100 Subject: [PATCH 42/84] Name url to export calendar to ical --- gestioncof/templates/gestioncof/calendar_subscription.html | 2 +- gestioncof/tests/test_views.py | 2 +- gestioncof/urls.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/gestioncof/templates/gestioncof/calendar_subscription.html b/gestioncof/templates/gestioncof/calendar_subscription.html index b13cb7f2..4b9e3cbb 100644 --- a/gestioncof/templates/gestioncof/calendar_subscription.html +++ b/gestioncof/templates/gestioncof/calendar_subscription.html @@ -12,7 +12,7 @@ souscrire aux événements du COF et/ou aux spectacles BdA. {% if token %}

    Votre calendrier (compatible avec toutes les applications d'agenda) se trouve à -cette adresse.

    +cette adresse.

    • Pour l'ajouter à Thunderbird (lightning), il faut copier ce lien et aller diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py index e33fe2f2..06d6be4a 100644 --- a/gestioncof/tests/test_views.py +++ b/gestioncof/tests/test_views.py @@ -70,7 +70,7 @@ class CalendarViewTests(ViewTestCaseMixin, TestCase): class CalendarICSViewTests(ViewTestCaseMixin, TestCase): - url_name = 'gestioncof.views.calendar_ics' + url_name = 'calendar.ics' auth_user = None auth_forbidden = [] diff --git a/gestioncof/urls.py b/gestioncof/urls.py index 2be609b3..dde543a5 100644 --- a/gestioncof/urls.py +++ b/gestioncof/urls.py @@ -52,7 +52,8 @@ events_patterns = [ calendar_patterns = [ url(r'^subscription$', views.calendar, name='calendar'), - url(r'^(?P[a-z0-9-]+)/calendar.ics$', views.calendar_ics) + url(r'^(?P[a-z0-9-]+)/calendar.ics$', views.calendar_ics, + name='calendar.ics'), ] clubs_patterns = [ From 80ca35a4c0ff1f69916037ffba7713c4a57e5532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 22 Jan 2018 14:49:02 +0100 Subject: [PATCH 43/84] Add helper to check HttpResponse containing csv --- shared/tests/testcases.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/shared/tests/testcases.py b/shared/tests/testcases.py index 15792383..cac036af 100644 --- a/shared/tests/testcases.py +++ b/shared/tests/testcases.py @@ -1,3 +1,4 @@ +import csv from unittest import mock from urllib.parse import parse_qs, urlparse @@ -92,6 +93,10 @@ class TestCaseMixin: else: self.assertEqual(actual, expected) + def load_from_csv_response(self, r): + decoded = r.content.decode('utf-8') + return list(csv.reader(decoded.split('\n')[:-1])) + class ViewTestCaseMixin(TestCaseMixin): """ From f371606cdbd98298383a038879b6f51d5242b447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 22 Jan 2018 14:58:38 +0100 Subject: [PATCH 44/84] cof -- Add tests for export views --- gestioncof/tests/test_views.py | 159 +++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..8cd323cc --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,159 @@ +import csv + +from django.test import TestCase + +from gestioncof.models import Event +from gestioncof.tests.testcases import ViewTestCaseMixin + +from .utils import create_user + + +class ExportMembersViewTests(ViewTestCaseMixin, TestCase): + url_name = 'gestioncof.views.export_members' + url_expected = '/export/members' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + def test(self): + u1, u2 = self.users['member'], self.users['staff'] + u1.first_name = 'first' + u1.last_name = 'last' + u1.email = 'user@mail.net' + u1.save() + u1.profile.phone = '0123456789' + u1.profile.departement = 'Dept' + u1.profile.save() + + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + data = list(csv.reader(r.content.decode('utf-8').split('\n')[:-1])) + self.assertListEqual(data, [ + [ + str(u1.pk), 'member', 'first', 'last', 'user@mail.net', + '0123456789', '1A', 'Dept', 'normalien', + ], + [str(u2.pk), 'staff', '', '', '', '', '1A', '', 'normalien'], + ]) + + +class MegaHelpers: + + def setUp(self): + super().setUp() + + u1 = create_user('u1') + u1.first_name = 'first' + u1.last_name = 'last' + u1.email = 'user@mail.net' + u1.save() + u1.profile.phone = '0123456789' + u1.profile.departement = 'Dept' + u1.profile.comments = 'profile.comments' + u1.profile.save() + + u2 = create_user('u2') + u2.profile.save() + + m = Event.objects.create(title='MEGA 2017') + + cf1 = m.commentfields.create(name='Commentaire') + cf2 = m.commentfields.create( + name='Comment Field 2', fieldtype='char', + ) + + option_type = m.options.create(name='Conscrit/Orga ?') + choice_orga = option_type.choices.create(value='Orga') + choice_conscrit = option_type.choices.create(value='Conscrit') + + mr1 = m.eventregistration_set.create(user=u1) + mr1.options.add(choice_orga) + mr1.comments.create(commentfield=cf1, content='Comment 1') + mr1.comments.create(commentfield=cf2, content='Comment 2') + + mr2 = m.eventregistration_set.create(user=u2) + mr2.options.add(choice_conscrit) + + self.u1 = u1 + self.u2 = u2 + self.m = m + self.choice_orga = choice_orga + self.choice_conscrit = choice_conscrit + + +class ExportMegaViewTests(MegaHelpers, ViewTestCaseMixin, TestCase): + url_name = 'gestioncof.views.export_mega' + url_expected = '/export/mega' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + def test(self): + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertListEqual(self.load_from_csv_response(r), [ + [ + 'u1', 'first', 'last', 'user@mail.net', '0123456789', + str(self.u1.pk), 'profile.comments', 'Comment 1---Comment 2', + ], + ['u2', '', '', '', '', str(self.u2.pk), '', ''], + ]) + + +class ExportMegaOrgasViewTests(MegaHelpers, ViewTestCaseMixin, TestCase): + url_name = 'gestioncof.views.export_mega_orgas' + url_expected = '/export/mega/orgas' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + def test(self): + + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertListEqual(self.load_from_csv_response(r), [ + [ + 'u1', 'first', 'last', 'user@mail.net', '0123456789', + str(self.u1.pk), 'profile.comments', 'Comment 1---Comment 2', + ], + ]) + + +class ExportMegaParticipantsViewTests( + MegaHelpers, ViewTestCaseMixin, TestCase): + url_name = 'gestioncof.views.export_mega_participants' + url_expected = '/export/mega/participants' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + def test(self): + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertListEqual(self.load_from_csv_response(r), [ + ['u2', '', '', '', '', str(self.u2.pk), '', ''], + ]) + + +class ExportMegaRemarksViewTests( + MegaHelpers, ViewTestCaseMixin, TestCase): + url_name = 'gestioncof.views.export_mega_remarksonly' + url_expected = '/export/mega/avecremarques' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + def test(self): + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertListEqual(self.load_from_csv_response(r), [ + [ + 'u1', 'first', 'last', 'user@mail.net', '0123456789', + str(self.u1.pk), 'profile.comments', 'Comment 1', + ], + ]) From a813507ddd6218e51769f8a0b8e17ddc7f04115f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 22 Jan 2018 14:59:57 +0100 Subject: [PATCH 45/84] Name urls of export views (cof members, mega) --- gestioncof/templates/gestioncof/utile_cof.html | 8 ++++---- gestioncof/tests/test_views.py | 10 +++++----- gestioncof/urls.py | 15 ++++++++++----- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/gestioncof/templates/gestioncof/utile_cof.html b/gestioncof/templates/gestioncof/utile_cof.html index ae024949..8cea33df 100644 --- a/gestioncof/templates/gestioncof/utile_cof.html +++ b/gestioncof/templates/gestioncof/utile_cof.html @@ -7,15 +7,15 @@

      Liens utiles du COF

      COF

      Mega

      Note : pour ouvrir les fichiers .csv avec Excel, il faut diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py index 8cd323cc..02288e2b 100644 --- a/gestioncof/tests/test_views.py +++ b/gestioncof/tests/test_views.py @@ -9,7 +9,7 @@ from .utils import create_user class ExportMembersViewTests(ViewTestCaseMixin, TestCase): - url_name = 'gestioncof.views.export_members' + url_name = 'cof.membres_export' url_expected = '/export/members' auth_user = 'staff' @@ -83,7 +83,7 @@ class MegaHelpers: class ExportMegaViewTests(MegaHelpers, ViewTestCaseMixin, TestCase): - url_name = 'gestioncof.views.export_mega' + url_name = 'cof.mega_export' url_expected = '/export/mega' auth_user = 'staff' @@ -103,7 +103,7 @@ class ExportMegaViewTests(MegaHelpers, ViewTestCaseMixin, TestCase): class ExportMegaOrgasViewTests(MegaHelpers, ViewTestCaseMixin, TestCase): - url_name = 'gestioncof.views.export_mega_orgas' + url_name = 'cof.mega_export_orgas' url_expected = '/export/mega/orgas' auth_user = 'staff' @@ -124,7 +124,7 @@ class ExportMegaOrgasViewTests(MegaHelpers, ViewTestCaseMixin, TestCase): class ExportMegaParticipantsViewTests( MegaHelpers, ViewTestCaseMixin, TestCase): - url_name = 'gestioncof.views.export_mega_participants' + url_name = 'cof.mega_export_participants' url_expected = '/export/mega/participants' auth_user = 'staff' @@ -141,7 +141,7 @@ class ExportMegaParticipantsViewTests( class ExportMegaRemarksViewTests( MegaHelpers, ViewTestCaseMixin, TestCase): - url_name = 'gestioncof.views.export_mega_remarksonly' + url_name = 'cof.mega_export_remarks' url_expected = '/export/mega/avecremarques' auth_user = 'staff' diff --git a/gestioncof/urls.py b/gestioncof/urls.py index 2be609b3..ef3b4190 100644 --- a/gestioncof/urls.py +++ b/gestioncof/urls.py @@ -6,12 +6,17 @@ from gestioncof import views, petits_cours_views from gestioncof.decorators import buro_required export_patterns = [ - url(r'^members$', views.export_members), - url(r'^mega/avecremarques$', views.export_mega_remarksonly), - url(r'^mega/participants$', views.export_mega_participants), - url(r'^mega/orgas$', views.export_mega_orgas), + url(r'^members$', views.export_members, + name='cof.membres_export'), + url(r'^mega/avecremarques$', views.export_mega_remarksonly, + name='cof.mega_export_remarks'), + url(r'^mega/participants$', views.export_mega_participants, + name='cof.mega_export_participants'), + url(r'^mega/orgas$', views.export_mega_orgas, + name='cof.mega_export_orgas'), # url(r'^mega/(?P.+)$', views.export_mega_bytype), - url(r'^mega$', views.export_mega), + url(r'^mega$', views.export_mega, + name='cof.mega_export'), ] petitcours_patterns = [ From bd89dce11d72f6ecf86501cfaf920dac646c6914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 22 Jan 2018 21:38:01 +0100 Subject: [PATCH 46/84] Add testing helpers to create superuser --- gestioncof/tests/utils.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/gestioncof/tests/utils.py b/gestioncof/tests/utils.py index 8d55680a..7ba361b7 100644 --- a/gestioncof/tests/utils.py +++ b/gestioncof/tests/utils.py @@ -9,7 +9,9 @@ def _create_user(username, is_cof=False, is_staff=False, attrs=None): password = attrs.pop('password', username) - user_keys = ['first_name', 'last_name', 'email', 'is_staff'] + user_keys = [ + 'first_name', 'last_name', 'email', 'is_staff', 'is_superuser', + ] user_attrs = {k: v for k, v in attrs.items() if k in user_keys} profile_keys = [ @@ -49,3 +51,11 @@ def create_member(username, attrs=None): def create_staff(username, attrs=None): return _create_user(username, is_cof=True, is_staff=True, attrs=attrs) + + +def create_root(username, attrs=None): + if attrs is None: + attrs = {} + attrs.setdefault('is_staff', True) + attrs.setdefault('is_superuser', True) + return _create_user(username, attrs=attrs) From 91162addb95fcafbf555288cc7fdeafa6f910b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 22 Jan 2018 21:41:02 +0100 Subject: [PATCH 47/84] cof -- Add tests for some views --- gestioncof/tests/test_views.py | 131 +++++++++++++++++++++++++++++++++ gestioncof/views.py | 1 + 2 files changed, 132 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..9f13fb36 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,131 @@ +from django.contrib import messages +from django.contrib.messages.api import get_messages +from django.contrib.messages.storage.base import Message +from django.test import TestCase +from django.urls import reverse + +from gestioncof.tests.testcases import ViewTestCaseMixin + +from .utils import create_member, create_root, create_user + + +class HomeViewTests(ViewTestCaseMixin, TestCase): + url_name = 'home' + url_expected = '/' + + auth_user = 'user' + auth_forbidden = [None] + + def test(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class ProfileViewTests(ViewTestCaseMixin, TestCase): + url_name = 'profile' + url_expected = '/profile' + + http_methods = ['GET', 'POST'] + + auth_user = 'member' + auth_forbidden = [None, 'user'] + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post(self): + u = self.users['member'] + + r = self.client.post(self.url, { + 'first_name': 'First', + 'last_name': 'Last', + 'phone': '', + # 'mailing_cof': '1', + # 'mailing_bda': '1', + # 'mailing_bda_revente': '1', + }) + + self.assertEqual(r.status_code, 200) + expected_message = Message(messages.SUCCESS, ( + "Votre profil a été mis à jour avec succès !" + )) + self.assertIn(expected_message, get_messages(r.wsgi_request)) + u.refresh_from_db() + self.assertEqual(u.first_name, 'First') + self.assertEqual(u.last_name, 'Last') + self.assertFalse(u.profile.mailing_cof) + self.assertFalse(u.profile.mailing_bda) + self.assertFalse(u.profile.mailing_bda_revente) + + +class UtilsViewTests(ViewTestCaseMixin, TestCase): + url_name = 'utile_cof' + url_expected = '/utile_cof' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + def test(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class MailingListDiffCof(ViewTestCaseMixin, TestCase): + url_name = 'gestioncof.views.liste_diffcof' + url_expected = '/utile_cof/diff_cof' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + def setUp(self): + super().setUp() + + self.u1 = create_member('u1', attrs={'mailing_cof': True}) + self.u2 = create_member('u2', attrs={'mailing_cof': False}) + self.u3 = create_user('u3', attrs={'mailing_cof': True}) + + def test(self): + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertEqual(r.context['personnes'].get(), self.u1.profile) + + +class ConfigUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'config.edit' + url_expected = '/config' + + http_methods = ['GET', 'POST'] + + auth_user = 'root' + auth_forbidden = [None, 'user', 'member', 'staff'] + + def get_users_extra(self): + return { + 'root': create_root('root'), + } + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post(self): + r = self.client.post(self.url, { + 'gestion_banner': 'Announcement !', + }) + + self.assertRedirects(r, reverse('home')) + + +class UserAutocompleteViewTests(ViewTestCaseMixin, TestCase): + url_name = 'cof-user-autocomplete' + url_expected = '/user/autocomplete' + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + def test(self): + r = self.client.get(self.url, {'q': 'user'}) + + self.assertEqual(r.status_code, 200) diff --git a/gestioncof/views.py b/gestioncof/views.py index 5dfee83f..2039bb65 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -9,6 +9,7 @@ from django.http import Http404, HttpResponse, HttpResponseForbidden from django.contrib.auth.decorators import login_required from django.contrib.auth.views import ( login as django_login_view, logout as django_logout_view, + redirect_to_login, ) from django.contrib.auth.models import User from django.contrib.sites.models import Site From 0876a004e55a04cf638903a2aa423e608a3654bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 22 Jan 2018 14:59:57 +0100 Subject: [PATCH 48/84] Name urls of export views (cof members, mega) --- gestioncof/templates/gestioncof/utile_cof.html | 8 ++++---- gestioncof/urls.py | 15 ++++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/gestioncof/templates/gestioncof/utile_cof.html b/gestioncof/templates/gestioncof/utile_cof.html index ae024949..8cea33df 100644 --- a/gestioncof/templates/gestioncof/utile_cof.html +++ b/gestioncof/templates/gestioncof/utile_cof.html @@ -7,15 +7,15 @@

      Liens utiles du COF

      COF

      Mega

      Note : pour ouvrir les fichiers .csv avec Excel, il faut diff --git a/gestioncof/urls.py b/gestioncof/urls.py index 2be609b3..ef3b4190 100644 --- a/gestioncof/urls.py +++ b/gestioncof/urls.py @@ -6,12 +6,17 @@ from gestioncof import views, petits_cours_views from gestioncof.decorators import buro_required export_patterns = [ - url(r'^members$', views.export_members), - url(r'^mega/avecremarques$', views.export_mega_remarksonly), - url(r'^mega/participants$', views.export_mega_participants), - url(r'^mega/orgas$', views.export_mega_orgas), + url(r'^members$', views.export_members, + name='cof.membres_export'), + url(r'^mega/avecremarques$', views.export_mega_remarksonly, + name='cof.mega_export_remarks'), + url(r'^mega/participants$', views.export_mega_participants, + name='cof.mega_export_participants'), + url(r'^mega/orgas$', views.export_mega_orgas, + name='cof.mega_export_orgas'), # url(r'^mega/(?P.+)$', views.export_mega_bytype), - url(r'^mega$', views.export_mega), + url(r'^mega$', views.export_mega, + name='cof.mega_export'), ] petitcours_patterns = [ From f8361b9114700facc4ebfcaa79d024be49496193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 22 Jan 2018 21:53:19 +0100 Subject: [PATCH 49/84] Add & fix urls naming --- cof/urls.py | 6 ++++-- gestioncof/templates/gestioncof/profile.html | 2 +- gestioncof/templates/gestioncof/utile_cof.html | 2 +- gestioncof/tests/test_views.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cof/urls.py b/cof/urls.py index e6e5d313..33d4fbc6 100644 --- a/cof/urls.py +++ b/cof/urls.py @@ -86,13 +86,15 @@ urlpatterns = [ url(r'^utile_bda$', gestioncof_views.utile_bda, name='utile_bda'), url(r'^utile_bda/bda_diff$', gestioncof_views.liste_bdadiff), - url(r'^utile_cof/diff_cof$', gestioncof_views.liste_diffcof), + url(r'^utile_cof/diff_cof$', gestioncof_views.liste_diffcof, + name='ml_diffcof'), url(r'^utile_bda/bda_revente$', gestioncof_views.liste_bdarevente), url(r'^k-fet/', include('kfet.urls')), url(r'^cms/', include(wagtailadmin_urls)), url(r'^documents/', include(wagtaildocs_urls)), # djconfig - url(r"^config", gestioncof_views.ConfigUpdate.as_view()), + url(r"^config", gestioncof_views.ConfigUpdate.as_view(), + name='config.edit'), ] if 'debug_toolbar' in settings.INSTALLED_APPS: diff --git a/gestioncof/templates/gestioncof/profile.html b/gestioncof/templates/gestioncof/profile.html index 59358239..5decdfb3 100644 --- a/gestioncof/templates/gestioncof/profile.html +++ b/gestioncof/templates/gestioncof/profile.html @@ -5,7 +5,7 @@ {% block realcontent %}

      Modifier mon profil

      - +
      {% csrf_token %} diff --git a/gestioncof/templates/gestioncof/utile_cof.html b/gestioncof/templates/gestioncof/utile_cof.html index 8cea33df..637055c5 100644 --- a/gestioncof/templates/gestioncof/utile_cof.html +++ b/gestioncof/templates/gestioncof/utile_cof.html @@ -8,7 +8,7 @@

      COF

      Mega

      diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py index 9f13fb36..6f8e95c1 100644 --- a/gestioncof/tests/test_views.py +++ b/gestioncof/tests/test_views.py @@ -72,7 +72,7 @@ class UtilsViewTests(ViewTestCaseMixin, TestCase): class MailingListDiffCof(ViewTestCaseMixin, TestCase): - url_name = 'gestioncof.views.liste_diffcof' + url_name = 'ml_diffcof' url_expected = '/utile_cof/diff_cof' auth_user = 'staff' From 0235c4f7e8e4d04961419e17fd745384294629d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 22 Jan 2018 21:54:41 +0100 Subject: [PATCH 50/84] Fix profile edition view - Fix a typo. - Bump version of django-bootstrap-form to be comaptible with Django 1.11. --- gestioncof/views.py | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gestioncof/views.py b/gestioncof/views.py index 2039bb65..6a2582e2 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -339,7 +339,7 @@ def profile(request): if form.is_valid(): form.save() messages.success(request, - "Votre profil a été mis à jour avec succès !") + "Votre profil a été mis à jour avec succès !") else: form = UserProfileForm(instance=request.user.profile) return render(request, "gestioncof/profile.html", {"form": form}) diff --git a/requirements.txt b/requirements.txt index d1046042..b30660ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ psycopg2 Pillow six unicodecsv -django-bootstrap-form==3.2.1 +django-bootstrap-form==3.3 asgiref==1.1.1 daphne==1.3.0 asgi-redis==1.3.0 From afa6972280fa560402e884f165193fda955f12f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 22 Jan 2018 21:59:07 +0100 Subject: [PATCH 51/84] Better handling of non-authorized users in config edition view --- gestioncof/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gestioncof/views.py b/gestioncof/views.py index 6a2582e2..2f6fd518 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -783,7 +783,7 @@ class ConfigUpdate(FormView): def dispatch(self, request, *args, **kwargs): if request.user is None or not request.user.is_superuser: - raise Http404 + return redirect_to_login(request.get_full_path()) return super().dispatch(request, *args, **kwargs) def form_valid(self, form): From ac1a57d96993466d38a2e87edcbcd1e5501b8c54 Mon Sep 17 00:00:00 2001 From: Basile Clement Date: Sun, 11 Feb 2018 17:01:26 +0100 Subject: [PATCH 52/84] Make provisioning script stop immediately on errors By default, bash will ignore any failing commands and happily proceed to execute the next ones. This is usually not the behavior the we want in provisioning script (or ever in scripts, actually): if one step of the provisioning fails, it doesn't make much sense to proceed with the following ones. This simple patch uses `set -e` to ask bash to abort the whole script if any command within it fails, leading to outputs that are easier to parse since the commands following a failing one will usually fail also, hiding the root cause. --- provisioning/bootstrap.sh | 3 +++ provisioning/prepare_django.sh | 3 +++ 2 files changed, 6 insertions(+) diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index 69bbcf4c..cb6917a7 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -1,5 +1,8 @@ #!/bin/sh +# Stop if an error is encountered +set -e + # Configuration de la base de données. Le mot de passe est constant car c'est # pour une installation de dév locale qui ne sera accessible que depuis la # machine virtuelle. diff --git a/provisioning/prepare_django.sh b/provisioning/prepare_django.sh index 1818a0cd..891108e8 100644 --- a/provisioning/prepare_django.sh +++ b/provisioning/prepare_django.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Stop if an error is encountered. +set -e + python manage.py migrate python manage.py loaddata gestion sites articles python manage.py loaddevdata From 3314670cab2917f3d57e3340c56a7a5daddd92c8 Mon Sep 17 00:00:00 2001 From: Basile Clement Date: Sun, 11 Feb 2018 19:09:07 +0100 Subject: [PATCH 53/84] Various fixes for Django 1.11 - The {% cycle %} command was used non-quoted arguments separated by commas, while it is supposed to use quoted arguments separated by spaces (I'm actually not sure how that ever worked :) - django-bootstrap-form was at version 3.2.1 which is not compatible with Django 1.11 (but also required by GestioCOF). I upgraded it to version 3.3. --- bda/templates/bda/inscription-formset.html | 2 +- gestioncof/templates/inscription-petit-cours-formset.html | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bda/templates/bda/inscription-formset.html b/bda/templates/bda/inscription-formset.html index 65ef389b..88b65600 100644 --- a/bda/templates/bda/inscription-formset.html +++ b/bda/templates/bda/inscription-formset.html @@ -14,7 +14,7 @@ {% endif %} - + {% for field in form.visible_fields %} {% if field.name != "DELETE" and field.name != "priority" %} diff --git a/gestioncof/templates/inscription-petit-cours-formset.html b/gestioncof/templates/inscription-petit-cours-formset.html index ec8979f5..40311772 100644 --- a/gestioncof/templates/inscription-petit-cours-formset.html +++ b/gestioncof/templates/inscription-petit-cours-formset.html @@ -16,7 +16,7 @@ {% endif %} - + {% for field in form.visible_fields %} {% if field.name != "DELETE" and field.name != "priority" %} diff --git a/requirements.txt b/requirements.txt index d1046042..b30660ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ psycopg2 Pillow six unicodecsv -django-bootstrap-form==3.2.1 +django-bootstrap-form==3.3 asgiref==1.1.1 daphne==1.3.0 asgi-redis==1.3.0 From 6ecc9a54b36d153dea502057531dd5b6bb7ea8cc Mon Sep 17 00:00:00 2001 From: Basile Clement Date: Sun, 11 Feb 2018 19:24:01 +0100 Subject: [PATCH 54/84] Properly propagate the default number of places in tirage Fixes #182. --- bda/templates/bda/inscription-tirage.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bda/templates/bda/inscription-tirage.html b/bda/templates/bda/inscription-tirage.html index d56b4229..3fd81378 100644 --- a/bda/templates/bda/inscription-tirage.html +++ b/bda/templates/bda/inscription-tirage.html @@ -27,6 +27,14 @@ var django = { var newFor = $(this).attr('for').replace('-' + (total-1) + '-','-' + total + '-'); $(this).attr('for', newFor); }); + // Cloning -
      - +

      Modifier mon profil

      +
      +
      + {% csrf_token %} + {{ user_form | bootstrap }} + {{ profile_form | bootstrap }} +
      + + {% if user.profile.comments %} +
      +

      Commentaires

      +

      {{ user.profile.comments }}

      +
      + {% endif %} + +
      + +
      +
      {% endblock %} diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py index 47139327..1067e586 100644 --- a/gestioncof/tests/test_views.py +++ b/gestioncof/tests/test_views.py @@ -46,9 +46,9 @@ class ProfileViewTests(ViewTestCaseMixin, TestCase): u = self.users['member'] r = self.client.post(self.url, { - 'first_name': 'First', - 'last_name': 'Last', - 'phone': '', + 'u-first_name': 'First', + 'u-last_name': 'Last', + 'p-phone': '', # 'mailing_cof': '1', # 'mailing_bda': '1', # 'mailing_bda_revente': '1', diff --git a/gestioncof/views.py b/gestioncof/views.py index 6126eb10..64fe89a0 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -32,7 +32,8 @@ from gestioncof.models import EventCommentField, EventCommentValue, \ from gestioncof.models import CofProfile, Club from gestioncof.decorators import buro_required, cof_required from gestioncof.forms import ( - UserProfileForm, EventStatusFilterForm, SurveyForm, SurveyStatusFilterForm, + UserForm, ProfileForm, + EventStatusFilterForm, SurveyForm, SurveyStatusFilterForm, RegistrationUserForm, RegistrationProfileForm, EventForm, CalendarForm, EventFormset, RegistrationPassUserForm, ClubsForm, GestioncofConfigForm ) @@ -334,15 +335,20 @@ def survey_status(request, survey_id): @cof_required def profile(request): + user = request.user + data = request.POST if request.method == "POST" else None + user_form = UserForm(data=data, instance=user, prefix="u") + profile_form = ProfileForm(data=data, instance=user.profile, prefix="p") if request.method == "POST": - form = UserProfileForm(request.POST, instance=request.user.profile) - if form.is_valid(): - form.save() - messages.success(request, - "Votre profil a été mis à jour avec succès !") - else: - form = UserProfileForm(instance=request.user.profile) - return render(request, "gestioncof/profile.html", {"form": form}) + if user_form.is_valid() and profile_form.is_valid(): + user_form.save() + profile_form.save() + messages.success( + request, + _("Votre profil a été mis à jour avec succès !") + ) + context = {"user_form": user_form, "profile_form": profile_form} + return render(request, "gestioncof/profile.html", context) def registration_set_ro_fields(user_form, profile_form): From 66184fbee649a3eefc7ab6673f05b5814ee74721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sun, 5 Aug 2018 18:34:05 +0200 Subject: [PATCH 79/84] CI: set python version to 3.5 --- .gitlab-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 19bcc736..e0ced08d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,3 +1,5 @@ +image: "python:3.5" + services: - postgres:latest - redis:latest @@ -34,6 +36,7 @@ before_script: # 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 --cache-dir vendor/pip -t vendor/python -r requirements.txt + - python --version test: stage: test From 91393dcea7caf4da586a66390c55ae88daed3272 Mon Sep 17 00:00:00 2001 From: Theo Delemazure Date: Sun, 2 Sep 2018 20:34:09 +0200 Subject: [PATCH 80/84] Update models.py --- gestioncof/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gestioncof/models.py b/gestioncof/models.py index 0d816155..8a5b6a53 100644 --- a/gestioncof/models.py +++ b/gestioncof/models.py @@ -72,6 +72,7 @@ class CofProfile(models.Model): TYPE_COTIZ_CHOICES)) mailing_cof = models.BooleanField("Recevoir les mails COF", default=False) mailing_bda = models.BooleanField("Recevoir les mails BdA", default=False) + mailing_unernestaparis = models.BooleanField("Recevoir les mails unErnestAParis", default=False) mailing_bda_revente = models.BooleanField( "Recevoir les mails de revente de places BdA", default=False) comments = models.TextField( From 73cf39baa81ae39c4f70192e2a6ac160c4e31fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sun, 2 Sep 2018 23:25:58 +0200 Subject: [PATCH 81/84] missing migration --- .../0014_cofprofile_mailing_unernestaparis.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 gestioncof/migrations/0014_cofprofile_mailing_unernestaparis.py diff --git a/gestioncof/migrations/0014_cofprofile_mailing_unernestaparis.py b/gestioncof/migrations/0014_cofprofile_mailing_unernestaparis.py new file mode 100644 index 00000000..1d842329 --- /dev/null +++ b/gestioncof/migrations/0014_cofprofile_mailing_unernestaparis.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-09-02 21:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gestioncof', '0013_pei'), + ] + + operations = [ + migrations.AddField( + model_name='cofprofile', + name='mailing_unernestaparis', + field=models.BooleanField(default=False, verbose_name='Recevoir les mails unErnestAParis'), + ), + ] From 327ef210dbc0e74f2b6c1968cebcee2c08c1beb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sun, 2 Sep 2018 23:26:18 +0200 Subject: [PATCH 82/84] make unernestaparis visible in forms --- gestioncof/forms.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/gestioncof/forms.py b/gestioncof/forms.py index 1fcc6ea6..4ad9b058 100644 --- a/gestioncof/forms.py +++ b/gestioncof/forms.py @@ -179,7 +179,13 @@ class UserForm(forms.ModelForm): class ProfileForm(forms.ModelForm): class Meta: model = CofProfile - fields = ["phone", "mailing_cof", "mailing_bda", "mailing_bda_revente"] + fields = [ + "phone", + "mailing_cof", + "mailing_bda", + "mailing_bda_revente", + "mailing_unernestaparis" + ] class RegistrationUserForm(forms.ModelForm): @@ -223,6 +229,7 @@ class RegistrationProfileForm(forms.ModelForm): self.fields['mailing_cof'].initial = True self.fields['mailing_bda'].initial = True self.fields['mailing_bda_revente'].initial = True + self.fields['mailing_unernestaparis'].initial = True self.fields.keyOrder = [ 'login_clipper', @@ -234,14 +241,16 @@ class RegistrationProfileForm(forms.ModelForm): 'mailing_cof', 'mailing_bda', 'mailing_bda_revente', + "mailing_unernestaparis", 'comments' - ] + ] class Meta: model = CofProfile fields = ("login_clipper", "phone", "occupation", "departement", "is_cof", "type_cotiz", "mailing_cof", - "mailing_bda", "mailing_bda_revente", "comments") + "mailing_bda", "mailing_bda_revente", + "mailing_unernestaparis", "comments") STATUS_CHOICES = (('no', 'Non'), From a750c62baf48cfab57d01f2d9b09372150fd06da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sun, 2 Sep 2018 23:27:21 +0200 Subject: [PATCH 83/84] New year, new promotion: 2018 --- kfet/migrations/0064_promo_2018.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 kfet/migrations/0064_promo_2018.py diff --git a/kfet/migrations/0064_promo_2018.py b/kfet/migrations/0064_promo_2018.py new file mode 100644 index 00000000..c99d85b5 --- /dev/null +++ b/kfet/migrations/0064_promo_2018.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-09-02 21:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0063_promo'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='promo', + field=models.IntegerField(blank=True, choices=[(1980, 1980), (1981, 1981), (1982, 1982), (1983, 1983), (1984, 1984), (1985, 1985), (1986, 1986), (1987, 1987), (1988, 1988), (1989, 1989), (1990, 1990), (1991, 1991), (1992, 1992), (1993, 1993), (1994, 1994), (1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017), (2018, 2018)], default=2018, null=True), + ), + ] From f297a1a0cf4b858a0d61b32be3b581fe9826df13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sun, 9 Sep 2018 07:20:18 +0200 Subject: [PATCH 84/84] =?UTF-8?q?update=20hardcoded=20Mega=20views=20for?= =?UTF-8?q?=202018=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gestioncof/tests/test_views.py | 6 ++--- gestioncof/views.py | 43 ++++++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py index 1067e586..f6dd7eb9 100644 --- a/gestioncof/tests/test_views.py +++ b/gestioncof/tests/test_views.py @@ -191,14 +191,14 @@ class MegaHelpers: u2 = create_user('u2') u2.profile.save() - m = Event.objects.create(title='MEGA 2017') + m = Event.objects.create(title='MEGA 2018') - cf1 = m.commentfields.create(name='Commentaire') + cf1 = m.commentfields.create(name='Commentaires') cf2 = m.commentfields.create( name='Comment Field 2', fieldtype='char', ) - option_type = m.options.create(name='Conscrit/Orga ?') + option_type = m.options.create(name='Orga ? Conscrit ?') choice_orga = option_type.choices.create(value='Orga') choice_conscrit = option_type.choices.create(value='Conscrit') diff --git a/gestioncof/views.py b/gestioncof/views.py index 64fe89a0..d77794bb 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -594,6 +594,19 @@ def export_members(request): return response +# ---------------------------------------- +# Début des exports Mega machins hardcodés +# ---------------------------------------- + + +MEGA_YEAR = 2018 +MEGA_EVENT_NAME = "MEGA 2018" +MEGA_COMMENTFIELD_NAME = "Commentaires" +MEGA_CONSCRITORGAFIELD_NAME = "Orga ? Conscrit ?" +MEGA_CONSCRIT = "Conscrit" +MEGA_ORGA = "Orga" + + def csv_export_mega(filename, qs): response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename=' + filename @@ -615,13 +628,13 @@ def csv_export_mega(filename, qs): @buro_required def export_mega_remarksonly(request): - filename = 'remarques_mega_2017.csv' + filename = 'remarques_mega_{}.csv'.format(MEGA_YEAR) response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename=' + filename writer = unicodecsv.writer(response) - event = Event.objects.get(title="MEGA 2017") - commentfield = event.commentfields.get(name="Commentaire") + event = Event.objects.get(title=MEGA_EVENT_NAME) + commentfield = event.commentfields.get(name=MEGA_COMMENTFIELD_NAME) for val in commentfield.values.all(): reg = val.registration user = reg.user @@ -653,32 +666,36 @@ def export_mega_remarksonly(request): @buro_required def export_mega_orgas(request): - event = Event.objects.get(title="MEGA 2017") - type_option = event.options.get(name="Conscrit/Orga ?") - participant_type = type_option.choices.get(value="Orga").id + event = Event.objects.get(title=MEGA_EVENT_NAME) + type_option = event.options.get(name=MEGA_CONSCRITORGAFIELD_NAME) + participant_type = type_option.choices.get(value=MEGA_ORGA).id qs = EventRegistration.objects.filter(event=event).filter( options__id=participant_type ) - return csv_export_mega('orgas_mega_2017.csv', qs) + return csv_export_mega('orgas_mega_{}.csv'.format(MEGA_YEAR), qs) @buro_required def export_mega_participants(request): - event = Event.objects.get(title="MEGA 2017") - type_option = event.options.get(name="Conscrit/Orga ?") - participant_type = type_option.choices.get(value="Conscrit").id + event = Event.objects.get(title=MEGA_EVENT_NAME) + type_option = event.options.get(name=MEGA_CONSCRITORGAFIELD_NAME) + participant_type = type_option.choices.get(value=MEGA_CONSCRIT).id qs = EventRegistration.objects.filter(event=event).filter( options__id=participant_type ) - return csv_export_mega('participants_mega_2017.csv', qs) + return csv_export_mega('conscrits_mega_{}.csv'.format(MEGA_YEAR), qs) @buro_required def export_mega(request): - event = Event.objects.filter(title="MEGA 2017") + event = Event.objects.filter(title=MEGA_EVENT_NAME) qs = EventRegistration.objects.filter(event=event) \ .order_by("user__username") - return csv_export_mega('all_mega_2017.csv', qs) + return csv_export_mega('all_mega_{}.csv'.format(MEGA_YEAR), qs) + +# ------------------------------ +# Fin des exports Mega hardcodés +# ------------------------------ @buro_required