diff --git a/.gitignore b/.gitignore index f12190af..ab791b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ venv/ /src media/ *.log +*.sqlite3 + +# PyCharm +.idea +.cache diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f2635b7b..19bcc736 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,25 +1,24 @@ services: - - mysql:latest + - postgres:latest - redis:latest variables: # GestioCOF settings - DJANGO_SETTINGS_MODULE: "cof.settings_dev" - DBNAME: "cof_gestion" - DBUSER: "cof_gestion" - DBPASSWD: "cof_password" - DBHOST: "mysql" + DJANGO_SETTINGS_MODULE: "cof.settings.prod" + DBHOST: "postgres" REDIS_HOST: "redis" + REDIS_PASSWD: "dummy" # Cached packages PYTHONPATH: "$CI_PROJECT_DIR/vendor/python" - # mysql service configuration - MYSQL_DATABASE: "$DBNAME" - MYSQL_USER: "$DBUSER" - MYSQL_PASSWORD: "$DBPASSWD" - MYSQL_ROOT_PASSWORD: "root_password" + # postgres service configuration + POSTGRES_PASSWORD: "4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4" + POSTGRES_USER: "cof_gestion" + POSTGRES_DB: "cof_gestion" + # psql password authentication + PGPASSWORD: $POSTGRES_PASSWORD cache: paths: @@ -29,13 +28,12 @@ cache: before_script: - mkdir -p vendor/{python,pip,apt} - - apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq mysql-client - - mysql --user=root --password="$MYSQL_ROOT_PASSWORD" --host="$DBHOST" - -e "GRANT ALL ON test_$DBNAME.* TO '$DBUSER'@'%'" + - apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client + - sed -E 's/^REDIS_HOST.*/REDIS_HOST = "redis"/' cof/settings/secret_example.py > cof/settings/secret.py + - sed -i.bak -E 's;^REDIS_PASSWD = .*$;REDIS_PASSWD = "";' cof/settings/secret.py # Remove the old test database if it has not been done yet - - mysql --user=root --password="$MYSQL_ROOT_PASSWORD" --host="$DBHOST" - -e "DROP DATABASE test_$DBNAME" || true - - pip install --cache-dir vendor/pip -t vendor/python -r requirements-devel.txt + - 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 test: stage: test diff --git a/README.md b/README.md index 1a3d575e..a0dc5bc1 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,75 @@ # GestioCOF +![build_status](https://git.eleves.ens.fr/cof-geek/gestioCOF/badges/master/build.svg) + ## Installation +Il est possible d'installer GestioCOF sur votre machine de deux façons différentes : + +- L'[installation manuelle](#installation-manuelle) (**recommandée** sous linux et OSX), plus légère +- L'[installation via vagrant](#vagrant) qui fonctionne aussi sous windows mais un peu plus lourde + +### Installation manuelle + +Il est fortement conseillé d'utiliser un environnement virtuel pour Python. + +Il vous faudra installer pip, les librairies de développement de python ainsi +que sqlite3, un moteur de base de données léger et simple d'utilisation. Sous +Debian et dérivées (Ubuntu, ...) : + + sudo apt-get install python3-pip python3-dev python3-venv sqlite3 + +Si vous décidez d'utiliser un environnement virtuel Python (virtualenv; +fortement conseillé), déplacez-vous dans le dossier où est installé GestioCOF +(le dossier où se trouve ce README), et créez-le maintenant : + + python3 -m venv venv + +Pour l'activer, il faut taper + + . venv/bin/activate + +depuis le même dossier. + +Vous pouvez maintenant installer les dépendances Python depuis le fichier +`requirements-devel.txt` : + + pip install -U pip # parfois nécessaire la première fois + pip install -r requirements-devel.txt + +Pour terminer, copier le fichier `cof/settings/secret_example.py` vers +`cof/settings/secret.py`. Sous Linux ou Mac, préférez plutôt un lien symbolique +pour profiter de façon transparente des mises à jour du fichier: + + ln -s secret_example.py cof/settings/secret.py + + +#### Fin d'installation + +Il ne vous reste plus qu'à initialiser les modèles de Django et peupler la base +de donnée avec les données nécessaires au bon fonctionnement de GestioCOF + des +données bidons bien pratiques pour développer avec la commande suivante : + + bash provisioning/prepare_django.sh + +Voir le paragraphe ["outils pour développer"](#outils-pour-d-velopper) plus bas +pour plus de détails. + +Vous êtes prêts à développer ! Lancer GestioCOF en faisant + + python manage.py runserver + + ### Vagrant -La façon recommandée d'installer GestioCOF sur votre machine est d'utiliser +Une autre façon d'installer GestioCOF sur votre machine est d'utiliser [Vagrant](https://www.vagrantup.com/). Vagrant permet de créer une machine virtuelle minimale sur laquelle tournera GestioCOF; ainsi on s'assure que tout le monde à la même configuration de développement (même sous Windows !), et l'installation se fait en une commande. Pour utiliser Vagrant, il faut le -[télécharger](https://www.vagrantup.com/downloads.html) et l'installer. +[télécharger](https://www.vagrantup.com/downloads.html) et l'installer. Si vous êtes sous Linux, votre distribution propose probablement des paquets Vagrant dans le gestionnaire de paquets (la version sera moins récente, ce qui @@ -66,137 +124,60 @@ car par défaut Django n'écoute que sur l'adresse locale de la machine virtuell or vous voudrez accéder à GestioCOF depuis votre machine physique. L'url à entrer dans le navigateur est `localhost:8000`. + #### Serveur de développement type production -Sur la VM Vagrant, un serveur apache est configuré pour servir GestioCOF de -façon similaire à la version en production : on utilise +Juste histoire de jouer, pas indispensable pour développer : + +La VM Vagrant héberge en plus un serveur nginx configuré pour servir GestioCOF +comme en production : on utilise [Daphne](https://github.com/django/daphne/) et `python manage.py runworker` -derrière un reverse-proxy apache. Le tout est monitoré par -[supervisor](http://supervisord.org/). +derrière un reverse-proxy nginx. Ce serveur se lance tout seul et est accessible en dehors de la VM à l'url -`localhost:8080`. Toutefois il ne se recharge pas tout seul lorsque le code -change, il faut relancer le worker avec `sudo supervisorctl restart worker` pour -visualiser la dernière version du code. - -### Installation manuelle - -Si vous optez pour une installation manuelle plutôt que d'utiliser Vagrant, il -est fortement conseillé d'utiliser un environnement virtuel pour Python. - -Il vous faudra installer pip, les librairies de développement de python, un -client et un serveur MySQL ainsi qu'un serveur redis ; sous Debian et dérivées -(Ubuntu, ...) : - - sudo apt-get install python-pip python-dev libmysqlclient-dev redis-server - -Si vous décidez d'utiliser un environnement virtuel Python (virtualenv; -fortement conseillé), déplacez-vous dans le dossier où est installé GestioCOF -(le dossier où se trouve ce README), et créez-le maintenant : - - virtualenv env -p $(which python3) - -L'option `-p` sert à préciser l'exécutable python à utiliser. Vous devez choisir -python3, si c'est la version de python par défaut sur votre système, ceci n'est -pas nécessaire. Pour l'activer, il faut faire - - . env/bin/activate - -dans le même dossier. - -Vous pouvez maintenant installer les dépendances Python depuis le fichier -`requirements-devel.txt` : - - pip install -r requirements-devel.txt - -Copiez le fichier `cof/settings_dev.py` dans `cof/settings.py`. - -#### Installation avec MySQL - -Il faut maintenant installer MySQL. Si vous n'avez pas déjà MySQL installé sur -votre serveur, il faut l'installer ; sous Debian et dérivées (Ubuntu, ...) : - - sudo apt-get install mysql-server - -Il vous demandera un mot de passe pour le compte d'administration MySQL, -notez-le quelque part (ou n'en mettez pas, le serveur n'est accessible que -localement par défaut). Si vous utilisez une autre distribution, consultez la -documentation de votre distribution pour savoir comment changer ce mot de passe -et démarrer le serveur MySQL (c'est automatique sous Ubuntu). - -Vous devez alors créer un utilisateur local et une base `cof_gestion`, avec le -mot de passe de votre choix (remplacez `mot_de_passe`) : - - mysql -uroot -e "CREATE DATABASE cof_gestion; GRANT ALL PRIVILEGES ON cof_gestion.* TO 'cof_gestion'@'localhost' IDENTIFIER BY 'mot_de_passe'" - -Éditez maintenant le fichier `cof/settings.py` pour y intégrer ces changements ; -la définition de `DATABASES` doit ressembler à (à nouveau, remplacez -`mot_de_passe` de façon appropriée) : - - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'cof_gestion', - 'USER': 'cof_gestion', - 'PASSWORD': 'mot_de_passe', - } - } - -#### Installation avec SQLite - -GestioCOF est installé avec MySQL sur la VM COF, et afin d'avoir un -environnement de développement aussi proche que possible de ce qui tourne en -vrai pour éviter les mauvaises surprises, il est conseillé d'utiliser MySQL sur -votre machine de développement également. Toutefois, GestioCOF devrait -fonctionner avec d'autres moteurs SQL, et certains préfèrent utiliser SQLite -pour sa légèreté et facilité d'installation. - -Si vous décidez d'utiliser SQLite, il faut l'installer ; sous Debian et dérivées : - - sudo apt-get install sqlite3 - -puis éditer le fichier `cof/settings.py` pour que la définition de `DATABASES` -ressemble à : - - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } - } - -#### Fin d'installation - -Il ne vous reste plus qu'à initialiser les modèles de Django avec la commande suivante : - - python manage.py migrate - -Charger les mails indispensables au bon fonctionnement de GestioCOF : - - python manage.py syncmails - -Une base de donnée pré-remplie est disponible en lançant les commandes : - - python manage.py loaddata gestion sites accounts groups articles - python manage.py loaddevdata - -Vous êtes prêts à développer ! Lancer GestioCOF en faisant - - python manage.py runserver +`localhost:8080/gestion/`. Toutefois il ne se recharge pas tout seul lorsque le +code change, il faut relancer le worker avec `sudo systemctl restart +worker.service` pour visualiser la dernière version du code. ### Mise à jour Pour mettre à jour les paquets Python, utiliser la commande suivante : - pip install --upgrade -r requirements.txt -r requirements-devel.txt + pip install --upgrade -r requirements-devel.txt Pour mettre à jour les modèles après une migration, il faut ensuite faire : python manage.py migrate +## Outils pour développer + +### Base de donnée + +Quelle que soit la méthode d'installation choisie, la base de donnée locale est + peuplée avec des données artificielles pour faciliter le développement. + +- Un compte `root` (mot de passe `root`) avec tous les accès est créé. Connectez + vous sur ce compte pour accéder à tout GestioCOF. +- Des comptes utilisateurs COF et non-COF sont créés ainsi que quelques + spectacles BdA et deux tirages au sort pour jouer avec les fonctionnalités du BdA. +- À chaque compte est associé un trigramme K-Fêt +- Un certain nombre d'articles K-Fêt sont renseignés. + +### Tests unitaires + +On écrit désormais des tests unitaires qui sont lancés automatiquement sur gitlab +à chaque push. Il est conseillé de lancer les tests sur sa machine avant de proposer un patch pour s'assurer qu'on ne casse pas une fonctionnalité existante. + +Pour lancer les tests : + +``` +python manage.py test +``` + + ## Documentation utilisateur -Une brève documentation utilisateur pour se familiariser plus vite avec l'outil -est accessible sur le -[wiki](https://git.eleves.ens.fr/cof-geek/gestioCOF/wikis/home). +Une brève documentation utilisateur est accessible sur le +[wiki](https://git.eleves.ens.fr/cof-geek/gestioCOF/wikis/home) pour avoir une +idée de la façon dont le COF utilise GestioCOF. diff --git a/TODO_PROD.md b/TODO_PROD.md new file mode 100644 index 00000000..1a7d0736 --- /dev/null +++ b/TODO_PROD.md @@ -0,0 +1 @@ +- Changer les urls dans les mails "bda-revente" et "bda-shotgun" diff --git a/bda/__init__.py b/bda/__init__.py index e69de29b..8b137891 100644 --- a/bda/__init__.py +++ b/bda/__init__.py @@ -0,0 +1 @@ + diff --git a/bda/admin.py b/bda/admin.py index 0cc66d43..485471da 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- - -import autocomplete_light from datetime import timedelta from custommail.shortcuts import send_mass_custom_mail @@ -9,6 +6,9 @@ from django.db.models import Sum, Count from django.template.defaultfilters import pluralize from django.utils import timezone from django import forms + +from dal.autocomplete import ModelSelect2 + from bda.models import Spectacle, Salle, Participant, ChoixSpectacle,\ Attribution, Tirage, Quote, CategorieSpectacle, SpectacleRevente @@ -24,8 +24,17 @@ class ReadOnlyMixin(object): return readonly_fields + self.readonly_fields_update +class ChoixSpectacleAdminForm(forms.ModelForm): + class Meta: + widgets = { + 'participant': ModelSelect2(url='bda-participant-autocomplete'), + 'spectacle': ModelSelect2(url='bda-spectacle-autocomplete'), + } + + class ChoixSpectacleInline(admin.TabularInline): model = ChoixSpectacle + form = ChoixSpectacleAdminForm sortable_field_name = "priority" @@ -56,17 +65,17 @@ class AttributionInline(admin.TabularInline): def get_queryset(self, request): qs = super().get_queryset(request) if self.listing is not None: - qs.filter(spectacle__listing=self.listing) + qs = qs.filter(spectacle__listing=self.listing) return qs class WithListingAttributionInline(AttributionInline): + exclude = ('given', ) form = WithListingAttributionTabularAdminForm listing = True class WithoutListingAttributionInline(AttributionInline): - exclude = ('given', ) form = WithoutListingAttributionTabularAdminForm listing = False @@ -155,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: @@ -180,7 +189,7 @@ class AttributionAdmin(ReadOnlyMixin, admin.ModelAdmin): class ChoixSpectacleAdmin(admin.ModelAdmin): - form = autocomplete_light.modelform_factory(ChoixSpectacle, exclude=[]) + form = ChoixSpectacleAdminForm def tirage(self, obj): return obj.participant.tirage @@ -225,7 +234,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') ) @@ -288,13 +297,7 @@ class SpectacleReventeAdmin(admin.ModelAdmin): count = queryset.count() for revente in queryset.filter( attribution__spectacle__date__gte=timezone.now()): - revente.date = timezone.now() - timedelta(hours=1) - revente.soldTo = None - revente.notif_sent = False - revente.tirage_done = False - if revente.answered_mail: - revente.answered_mail.clear() - revente.save() + revente.reset(new_date=timezone.now() - timedelta(hours=1)) self.message_user( request, "%d attribution%s %s été réinitialisée%s avec succès." % ( diff --git a/bda/algorithm.py b/bda/algorithm.py index 7f18ce18..f0f48ad9 100644 --- a/bda/algorithm.py +++ b/bda/algorithm.py @@ -1,9 +1,3 @@ -# -*- coding: utf-8 -*- - -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 deleted file mode 100644 index 6c2f3ea6..00000000 --- a/bda/autocomplete_light_registry.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - -import autocomplete_light - -from bda.models import Participant, Spectacle - -autocomplete_light.register( - Participant, search_fields=('user__username', 'user__first_name', - 'user__last_name'), - autocomplete_js_attributes={'placeholder': 'participant...'}) - -autocomplete_light.register( - Spectacle, search_fields=('title', ), - autocomplete_js_attributes={'placeholder': 'spectacle...'}) diff --git a/bda/forms.py b/bda/forms.py index c0417d1e..7e81587a 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- - from django import forms from django.forms.models import BaseInlineFormSet from django.utils import timezone -from bda.models import Attribution, Spectacle +from bda.models import Attribution, Spectacle, SpectacleRevente class InscriptionInlineFormSet(BaseInlineFormSet): @@ -43,7 +41,33 @@ class TokenForm(forms.Form): class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, obj): - return "%s" % str(obj.spectacle) + return str(obj.spectacle) + + +class ReventeModelMultipleChoiceField(forms.ModelMultipleChoiceField): + def __init__(self, *args, own=True, **kwargs): + super().__init__(*args, **kwargs) + self.own = own + + def label_from_instance(self, obj): + label = "{show}{suffix}" + suffix = "" + if self.own: + # C'est notre propre revente : pas besoin de spécifier le vendeur + if obj.soldTo is not None: + suffix = " -- Vendue à {firstname} {lastname}".format( + firstname=obj.soldTo.user.first_name, + lastname=obj.soldTo.user.last_name, + ) + else: + # Ce n'est pas à nous : on ne voit jamais l'acheteur + suffix = " -- Vendue par {firstname} {lastname}".format( + firstname=obj.seller.user.first_name, + lastname=obj.seller.user.last_name, + ) + + return label.format(show=str(obj.attribution.spectacle), + suffix=suffix) class ResellForm(forms.Form): @@ -54,7 +78,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()) @@ -65,22 +89,22 @@ class ResellForm(forms.Form): class AnnulForm(forms.Form): - attributions = AttributionModelMultipleChoiceField( + reventes = ReventeModelMultipleChoiceField( + own=True, label='', queryset=Attribution.objects.none(), widget=forms.CheckboxSelectMultiple, required=False) 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') + super().__init__(*args, **kwargs) + 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') ) @@ -91,7 +115,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') @@ -99,19 +123,58 @@ class InscriptionReventeForm(forms.Form): ) +class ReventeTirageAnnulForm(forms.Form): + reventes = ReventeModelMultipleChoiceField( + own=False, + label='', + queryset=SpectacleRevente.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=False + ) + + def __init__(self, participant, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['reventes'].queryset = ( + participant.entered.filter(soldTo__isnull=True) + .select_related('attribution__spectacle', + 'seller__user') + ) + + +class ReventeTirageForm(forms.Form): + reventes = ReventeModelMultipleChoiceField( + own=False, + label='', + queryset=SpectacleRevente.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=False + ) + + def __init__(self, participant, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['reventes'].queryset = ( + SpectacleRevente.objects.filter( + notif_sent=True, + shotgun=False, + tirage_done=False + ).exclude(confirmed_entry=participant) + .select_related('attribution__spectacle') + ) + + class SoldForm(forms.Form): - attributions = AttributionModelMultipleChoiceField( + reventes = ReventeModelMultipleChoiceField( + own=True, label='', queryset=Attribution.objects.none(), widget=forms.CheckboxSelectMultiple) def __init__(self, participant, *args, **kwargs): - super(SoldForm, self).__init__(*args, **kwargs) - self.fields['attributions'].queryset = ( - participant.attribution_set - .filter(revente__isnull=False, - revente__soldTo__isnull=False) - .exclude(revente__soldTo=participant) - .select_related('spectacle', 'spectacle__location', - 'participant__user') + super().__init__(*args, **kwargs) + self.fields['reventes'].queryset = ( + participant.original_shows + .filter(soldTo__isnull=False) + .exclude(soldTo=participant) + .select_related('attribution__spectacle', + 'attribution__spectacle__location') ) diff --git a/bda/management/commands/manage_reventes.py b/bda/management/commands/manage_reventes.py index 0302ec4b..5a06d40b 100644 --- a/bda/management/commands/manage_reventes.py +++ b/bda/management/commands/manage_reventes.py @@ -1,12 +1,7 @@ -# -*- coding: utf-8 -*- - """ 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 +16,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/management/commands/sendrappels.py b/bda/management/commands/sendrappels.py index 88cf9d5c..82889f80 100644 --- a/bda/management/commands/sendrappels.py +++ b/bda/management/commands/sendrappels.py @@ -1,11 +1,7 @@ -# -*- coding: utf-8 -*- - """ 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/migrations/0001_initial.py b/bda/migrations/0001_initial.py index aa2cb252..c4494413 100644 --- a/bda/migrations/0001_initial.py +++ b/bda/migrations/0001_initial.py @@ -59,7 +59,7 @@ class Migration(migrations.Migration): ('price', models.FloatField(verbose_name=b"Prix d'une place", blank=True)), ('slots', models.IntegerField(verbose_name=b'Places')), ('priority', models.IntegerField(default=1000, verbose_name=b'Priorit\xc3\xa9')), - ('location', models.ForeignKey(to='bda.Salle')), + ('location', models.ForeignKey(to='bda.Salle', on_delete=models.CASCADE)), ], options={ 'ordering': ('priority', 'date', 'title'), @@ -79,27 +79,27 @@ class Migration(migrations.Migration): migrations.AddField( model_name='participant', name='user', - field=models.OneToOneField(to=settings.AUTH_USER_MODEL), + field=models.OneToOneField(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), ), migrations.AddField( model_name='choixspectacle', name='participant', - field=models.ForeignKey(to='bda.Participant'), + field=models.ForeignKey(to='bda.Participant', on_delete=models.CASCADE), ), migrations.AddField( model_name='choixspectacle', name='spectacle', - field=models.ForeignKey(related_name='participants', to='bda.Spectacle'), + field=models.ForeignKey(related_name='participants', to='bda.Spectacle', on_delete=models.CASCADE), ), migrations.AddField( model_name='attribution', name='participant', - field=models.ForeignKey(to='bda.Participant'), + field=models.ForeignKey(to='bda.Participant', on_delete=models.CASCADE), ), migrations.AddField( model_name='attribution', name='spectacle', - field=models.ForeignKey(related_name='attribues', to='bda.Spectacle'), + field=models.ForeignKey(related_name='attribues', to='bda.Spectacle', on_delete=models.CASCADE), ), migrations.AlterUniqueTogether( name='choixspectacle', diff --git a/bda/migrations/0002_add_tirage.py b/bda/migrations/0002_add_tirage.py index 1956a4a4..79f79a57 100644 --- a/bda/migrations/0002_add_tirage.py +++ b/bda/migrations/0002_add_tirage.py @@ -5,17 +5,34 @@ from django.db import migrations, models from django.conf import settings from django.utils import timezone -def forwards_func(apps, schema_editor): + +def fill_tirage_fields(apps, schema_editor): + """ + Create a `Tirage` to fill new field `tirage` of `Participant` + and `Spectacle` already existing. + """ + Participant = apps.get_model("bda", "Participant") + Spectacle = apps.get_model("bda", "Spectacle") Tirage = apps.get_model("bda", "Tirage") - db_alias = schema_editor.connection.alias - Tirage.objects.using(db_alias).bulk_create([ - Tirage( - id=1, - title="Tirage de test (migration)", - active=False, - ouverture=timezone.now(), - fermeture=timezone.now()), - ]) + + # These querysets only contains instances not linked to any `Tirage`. + participants = Participant.objects.filter(tirage=None) + spectacles = Spectacle.objects.filter(tirage=None) + + if not participants.count() and not spectacles.count(): + # No need to create a "trash" tirage. + return + + tirage = Tirage.objects.create( + title="Tirage de test (migration)", + active=False, + ouverture=timezone.now(), + fermeture=timezone.now(), + ) + + participants.update(tirage=tirage) + spectacles.update(tirage=tirage) + class Migration(migrations.Migration): @@ -35,22 +52,33 @@ class Migration(migrations.Migration): ('active', models.BooleanField(default=True, verbose_name=b'Tirage actif')), ], ), - migrations.RunPython(forwards_func, migrations.RunPython.noop), migrations.AlterField( model_name='participant', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), ), + # Create fields `spectacle` for `Participant` and `Spectacle` models. + # These fields are not nullable, but we first create them as nullable + # to give a default value for existing instances of these models. migrations.AddField( model_name='participant', name='tirage', - field=models.ForeignKey(default=1, to='bda.Tirage'), - preserve_default=False, + field=models.ForeignKey(to='bda.Tirage', null=True, on_delete=models.CASCADE), ), migrations.AddField( model_name='spectacle', name='tirage', - field=models.ForeignKey(default=1, to='bda.Tirage'), - preserve_default=False, + field=models.ForeignKey(to='bda.Tirage', null=True, on_delete=models.CASCADE), + ), + migrations.RunPython(fill_tirage_fields, migrations.RunPython.noop), + migrations.AlterField( + model_name='participant', + name='tirage', + field=models.ForeignKey(to='bda.Tirage', on_delete=models.CASCADE), + ), + migrations.AlterField( + model_name='spectacle', + name='tirage', + field=models.ForeignKey(to='bda.Tirage', on_delete=models.CASCADE), ), ] diff --git a/bda/migrations/0007_extends_spectacle.py b/bda/migrations/0007_extends_spectacle.py index b95c18de..6ea11dc0 100644 --- a/bda/migrations/0007_extends_spectacle.py +++ b/bda/migrations/0007_extends_spectacle.py @@ -73,6 +73,7 @@ class Migration(migrations.Migration): model_name='spectacle', name='category', field=models.ForeignKey(blank=True, to='bda.CategorieSpectacle', + on_delete=models.CASCADE, null=True), ), migrations.AddField( @@ -84,6 +85,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='quote', name='spectacle', - field=models.ForeignKey(to='bda.Spectacle'), + field=models.ForeignKey(to='bda.Spectacle', + on_delete=models.CASCADE), ), ] diff --git a/bda/migrations/0009_revente.py b/bda/migrations/0009_revente.py index 1cca4e86..70d6f338 100644 --- a/bda/migrations/0009_revente.py +++ b/bda/migrations/0009_revente.py @@ -47,12 +47,14 @@ class Migration(migrations.Migration): model_name='spectaclerevente', name='attribution', field=models.OneToOneField(to='bda.Attribution', + on_delete=models.CASCADE, related_name='revente'), ), migrations.AddField( model_name='spectaclerevente', name='seller', field=models.ForeignKey(to='bda.Participant', + on_delete=models.CASCADE, verbose_name='Vendeur', related_name='original_shows'), ), @@ -60,6 +62,7 @@ class Migration(migrations.Migration): model_name='spectaclerevente', name='soldTo', field=models.ForeignKey(to='bda.Participant', + on_delete=models.CASCADE, verbose_name='Vendue à', null=True, blank=True), ), diff --git a/bda/migrations/0012_notif_time.py b/bda/migrations/0012_notif_time.py new file mode 100644 index 00000000..ee777e35 --- /dev/null +++ b/bda/migrations/0012_notif_time.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bda', '0011_tirage_appear_catalogue'), + ] + + operations = [ + migrations.RenameField( + model_name='spectaclerevente', + old_name='answered_mail', + new_name='confirmed_entry', + ), + migrations.AlterField( + model_name='spectaclerevente', + name='confirmed_entry', + field=models.ManyToManyField(blank=True, related_name='entered', to='bda.Participant'), + ), + migrations.AddField( + model_name='spectaclerevente', + name='notif_time', + field=models.DateTimeField(blank=True, verbose_name="Moment d'envoi de la notification", null=True), + ), + ] diff --git a/bda/migrations/0012_swap_double_choice.py b/bda/migrations/0012_swap_double_choice.py new file mode 100644 index 00000000..56f3e739 --- /dev/null +++ b/bda/migrations/0012_swap_double_choice.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +def swap_double_choice(apps, schema_editor): + choices = apps.get_model("bda", "ChoixSpectacle").objects + + choices.filter(double_choice="double").update(double_choice="tmp") + choices.filter(double_choice="autoquit").update(double_choice="double") + choices.filter(double_choice="tmp").update(double_choice="autoquit") + + +class Migration(migrations.Migration): + + dependencies = [ + ('bda', '0011_tirage_appear_catalogue'), + ] + + operations = [ + # Temporarily allow an extra "tmp" value for the `double_choice` field + migrations.AlterField( + model_name='choixspectacle', + name='double_choice', + field=models.CharField( + verbose_name='Nombre de places', + max_length=10, + default='1', + choices=[ + ('tmp', 'tmp'), + ('1', '1 place'), + ('double', '2 places si possible, 1 sinon'), + ('autoquit', '2 places sinon rien') + ] + ), + ), + migrations.RunPython(swap_double_choice, migrations.RunPython.noop), + migrations.AlterField( + model_name='choixspectacle', + name='double_choice', + field=models.CharField( + verbose_name='Nombre de places', + max_length=10, + default='1', + choices=[ + ('1', '1 place'), + ('double', '2 places si possible, 1 sinon'), + ('autoquit', '2 places sinon rien') + ] + ), + ), + ] diff --git a/bda/migrations/0013_merge_20180524_2123.py b/bda/migrations/0013_merge_20180524_2123.py new file mode 100644 index 00000000..ae8b0630 --- /dev/null +++ b/bda/migrations/0013_merge_20180524_2123.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-05-24 19:23 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bda', '0012_notif_time'), + ('bda', '0012_swap_double_choice'), + ] + + operations = [ + ] diff --git a/bda/models.py b/bda/models.py index 0228b4c0..63e01e31 100644 --- a/bda/models.py +++ b/bda/models.py @@ -1,16 +1,26 @@ -# -*- coding: utf-8 -*- - import calendar import random from datetime import timedelta from custommail.shortcuts import send_mass_custom_mail from django.contrib.sites.models import Site +from django.core import mail from django.db import models +from django.db.models import Count from django.contrib.auth.models import User from django.conf import settings from django.utils import timezone, formats +from custommail.models import CustomMail + + +def get_generic_user(): + generic, _ = User.objects.get_or_create( + username="bda_generic", + defaults={"email": "bda@ens.fr", "first_name": "Bureau des arts"} + ) + return generic + class Tirage(models.Model): title = models.CharField("Titre", max_length=300) @@ -50,9 +60,12 @@ class CategorieSpectacle(models.Model): class Spectacle(models.Model): title = models.CharField("Titre", max_length=300) - category = models.ForeignKey(CategorieSpectacle, blank=True, null=True) + category = models.ForeignKey( + CategorieSpectacle, on_delete=models.CASCADE, + blank=True, null=True, + ) date = models.DateTimeField("Date & heure") - location = models.ForeignKey(Salle) + location = models.ForeignKey(Salle, on_delete=models.CASCADE) vips = models.TextField('Personnalités', blank=True) description = models.TextField("Description", blank=True) slots_description = models.TextField("Description des places", blank=True) @@ -62,7 +75,7 @@ class Spectacle(models.Model): max_length=500) price = models.FloatField("Prix d'une place") slots = models.IntegerField("Places") - tirage = models.ForeignKey(Tirage) + tirage = models.ForeignKey(Tirage, on_delete=models.CASCADE) listing = models.BooleanField("Les places sont sur listing") rappel_sent = models.DateTimeField("Mail de rappel envoyé", blank=True, null=True) @@ -96,32 +109,29 @@ class Spectacle(models.Model): Envoie un mail de rappel à toutes les personnes qui ont une place pour ce spectacle. """ - # On récupère la liste des participants - members = {} - for attr in Attribution.objects.filter(spectacle=self).all(): - member = attr.participant.user - if member.id in members: - members[member.id][1] = 2 - else: - members[member.id] = [member, 1] - # FIXME : faire quelque chose de ça, un utilisateur bda_generic ? - # # Pour le BdA - # members[0] = ['BdA', 1, 'bda@ens.fr'] - # members[-1] = ['BdA', 2, 'bda@ens.fr'] + # On récupère la liste des participants + le BdA + members = list( + User.objects + .filter(participant__attributions=self) + .annotate(nb_attr=Count("id")).order_by() + ) + bda_generic = get_generic_user() + bda_generic.nb_attr = 1 + members.append(bda_generic) # On écrit un mail personnalisé à chaque participant datatuple = [( 'bda-rappel', - {'member': member[0], 'nb_attr': member[1], 'show': self}, + {'member': member, "nb_attr": member.nb_attr, 'show': self}, settings.MAIL_DATA['rappels']['FROM'], - [member[0].email]) - for member in members.values() + [member.email]) + for member in members ] send_mass_custom_mail(datatuple) # On enregistre le fait que l'envoi a bien eu lieu self.rappel_sent = timezone.now() self.save() # On renvoie la liste des destinataires - return members.values() + return members @property def is_past(self): @@ -129,7 +139,7 @@ class Spectacle(models.Model): class Quote(models.Model): - spectacle = models.ForeignKey(Spectacle) + spectacle = models.ForeignKey(Spectacle, on_delete=models.CASCADE) text = models.TextField('Citation') author = models.CharField('Auteur', max_length=200) @@ -143,7 +153,7 @@ PAYMENT_TYPES = ( class Participant(models.Model): - user = models.ForeignKey(User) + user = models.ForeignKey(User, on_delete=models.CASCADE) choices = models.ManyToManyField(Spectacle, through="ChoixSpectacle", related_name="chosen_by") @@ -154,7 +164,7 @@ class Participant(models.Model): paymenttype = models.CharField("Moyen de paiement", max_length=6, choices=PAYMENT_TYPES, blank=True) - tirage = models.ForeignKey(Tirage) + tirage = models.ForeignKey(Tirage, on_delete=models.CASCADE) choicesrevente = models.ManyToManyField(Spectacle, related_name="subscribed", blank=True) @@ -162,16 +172,20 @@ 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"), - ("double", "2 places sinon rien"), + ("double", "2 places si possible, 1 sinon"), + ("autoquit", "2 places sinon rien"), ) class ChoixSpectacle(models.Model): - participant = models.ForeignKey(Participant) - spectacle = models.ForeignKey(Spectacle, related_name="participants") + participant = models.ForeignKey(Participant, on_delete=models.CASCADE) + spectacle = models.ForeignKey( + Spectacle, on_delete=models.CASCADE, + related_name="participants", + ) priority = models.PositiveIntegerField("Priorité") double_choice = models.CharField("Nombre de places", default="1", choices=DOUBLE_CHOICES, @@ -198,8 +212,11 @@ class ChoixSpectacle(models.Model): class Attribution(models.Model): - participant = models.ForeignKey(Participant) - spectacle = models.ForeignKey(Spectacle, related_name="attribues") + participant = models.ForeignKey(Participant, on_delete=models.CASCADE) + spectacle = models.ForeignKey( + Spectacle, on_delete=models.CASCADE, + related_name="attribues", + ) given = models.BooleanField("Donnée", default=False) def __str__(self): @@ -208,36 +225,83 @@ class Attribution(models.Model): class SpectacleRevente(models.Model): - attribution = models.OneToOneField(Attribution, - related_name="revente") + attribution = models.OneToOneField( + Attribution, on_delete=models.CASCADE, + related_name="revente", + ) date = models.DateTimeField("Date de mise en vente", default=timezone.now) - answered_mail = models.ManyToManyField(Participant, - related_name="wanted", - blank=True) - seller = models.ForeignKey(Participant, - related_name="original_shows", - verbose_name="Vendeur") - soldTo = models.ForeignKey(Participant, blank=True, null=True, - verbose_name="Vendue à") + confirmed_entry = models.ManyToManyField(Participant, + related_name="entered", + blank=True) + seller = models.ForeignKey( + Participant, on_delete=models.CASCADE, + verbose_name="Vendeur", + related_name="original_shows", + ) + soldTo = models.ForeignKey( + Participant, on_delete=models.CASCADE, + verbose_name="Vendue à", + blank=True, null=True, + ) notif_sent = models.BooleanField("Notification envoyée", default=False) + + notif_time = models.DateTimeField("Moment d'envoi de la notification", + blank=True, null=True) + tirage_done = models.BooleanField("Tirage effectué", default=False) + shotgun = models.BooleanField("Disponible immédiatement", default=False) + #### + # Some class attributes + ### + # TODO : settings ? + + # Temps minimum entre le tirage et le spectacle + min_margin = timedelta(days=5) + + # Temps entre la création d'une revente et l'envoi du mail + remorse_time = timedelta(hours=1) + + # Temps min/max d'attente avant le tirage + max_wait_time = timedelta(days=3) + min_wait_time = timedelta(days=1) + + @property + def real_notif_time(self): + if self.notif_time: + return self.notif_time + else: + return self.date + self.remorse_time @property def date_tirage(self): """Renvoie la date du tirage au sort de la revente.""" - # L'acheteur doit être connu au plus 12h avant le spectacle + remaining_time = (self.attribution.spectacle.date - - self.date - timedelta(hours=13)) - # Au minimum, on attend 2 jours avant le tirage - delay = min(remaining_time, timedelta(days=2)) - # Le vendeur a aussi 1h pour changer d'avis - return self.date + delay + timedelta(hours=1) + - self.real_notif_time - self.min_margin) + + delay = min(remaining_time, self.max_wait_time) + + return self.real_notif_time + delay + + @property + def is_urgent(self): + """ + Renvoie True iff la revente doit être mise au shotgun directement. + Plus précisément, on doit avoir min_margin + min_wait_time de marge. + """ + spectacle_date = self.attribution.spectacle.date + return (spectacle_date <= timezone.now() + self.min_margin + + self.min_wait_time) + + @property + def can_notif(self): + return (timezone.now() >= self.date + self.remorse_time) def __str__(self): return "%s -- %s" % (self.seller, @@ -246,6 +310,18 @@ class SpectacleRevente(models.Model): class Meta: verbose_name = "Revente" + def reset(self, new_date=timezone.now()): + """Réinitialise la revente pour permettre une remise sur le marché""" + self.seller = self.attribution.participant + self.date = new_date + self.confirmed_entry.clear() + self.soldTo = None + self.notif_sent = False + self.notif_time = None + self.tirage_done = False + self.shotgun = False + self.save() + def send_notif(self): """ Envoie une notification pour indiquer la mise en vente d'une place sur @@ -266,6 +342,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): @@ -287,58 +364,79 @@ class SpectacleRevente(models.Model): ] send_mass_custom_mail(datatuple) self.notif_sent = True + self.notif_time = timezone.now() # Flag inutile, sauf si l'horloge interne merde self.tirage_done = True self.shotgun = True self.save() - def tirage(self): + def tirage(self, send_mails=True): """ Lance le tirage au sort associé à la revente. Un gagnant est choisi parmis les personnes intéressées par le spectacle. Les personnes sont ensuites prévenues par mail du résultat du tirage. """ - inscrits = list(self.answered_mail.all()) + inscrits = list(self.confirmed_entry.all()) spectacle = self.attribution.spectacle seller = self.seller + winner = None if inscrits: # Envoie un mail au gagnant et au vendeur winner = random.choice(inscrits) self.soldTo = winner - 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: + mails = [] - # 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) + context = { + 'acheteur': winner.user, + 'vendeur': seller.user, + 'show': spectacle, + } + + c_mails_qs = CustomMail.objects.filter(shortname__in=[ + 'bda-revente-winner', 'bda-revente-loser', + 'bda-revente-seller', + ]) + + c_mails = {cm.shortname: cm for cm in c_mails_qs} + + mails.append( + c_mails['bda-revente-winner'].get_message( + context, + from_email=settings.MAIL_DATA['revente']['FROM'], + to=[winner.user.email], + ) + ) + + mails.append( + c_mails['bda-revente-seller'].get_message( + context, + from_email=settings.MAIL_DATA['revente']['FROM'], + to=[seller.user.email], + reply_to=[winner.user.email], + ) + ) + + # Envoie un mail aux perdants + for inscrit in inscrits: + if inscrit != winner: + new_context = dict(context) + new_context['acheteur'] = inscrit.user + + mails.append( + c_mails['bda-revente-loser'].get_message( + new_context, + from_email=settings.MAIL_DATA['revente']['FROM'], + to=[inscrit.user.email], + ) + ) + + mail_conn = mail.get_connection() + mail_conn.send_messages(mails) # Si personne ne veut de la place, elle part au shotgun else: self.shotgun = True self.tirage_done = True self.save() + return winner diff --git a/bda/templates/bda/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/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 - - - -{% endblock %} diff --git a/bda/templates/bda/mails-rappel.html b/bda/templates/bda/mails-rappel.html index 73625d1c..c10503b0 100644 --- a/bda/templates/bda/mails-rappel.html +++ b/bda/templates/bda/mails-rappel.html @@ -3,41 +3,46 @@ {% block realcontent %}

Mails de rappels

{% if sent %} -

Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes

- +

Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes

+ {% else %} -

Voulez vous envoyer les mails de rappel pour le spectacle - {{ show.title }} ?

- {% if show.rappel_sent %} -

Attention, les mails ont déjà été envoyés le - {{ show.rappel_sent }}

- {% endif %} +

Voulez vous envoyer les mails de rappel pour le spectacle {{ show.title }} ?

{% endif %} - {% if not sent %} -
- {% csrf_token %} -
- -
-
- {% endif %} +
+ {% if not sent %} +
+ {% csrf_token %} +
+ +
+
+ {% endif %} +
+ +
+ +

+ Note : le template de ce mail peut être modifié à + cette adresse +

+ +
-
-

Forme des mails

Une seule place

- {% for part in exemple_mail_1place %} -
{{ part }}
- {% endfor %} + {% for part in exemple_mail_1place %} +
{{ part }}
+ {% endfor %}

Deux places

{% for part in exemple_mail_2places %} -
{{ part }}
+
{{ part }}
{% endfor %} + {% endblock %} diff --git a/bda/templates/bda-participants.html b/bda/templates/bda/participants.html similarity index 73% rename from bda/templates/bda-participants.html rename to bda/templates/bda/participants.html index 289d1761..c3ff31d6 100644 --- a/bda/templates/bda-participants.html +++ b/bda/templates/bda/participants.html @@ -36,17 +36,26 @@

Ajouter une attribution

-
- - -
- - + + +
+ Page d'envoi manuel des mails de rappel +
+ +{% endblock %} diff --git a/bda/templates/bda/revente/tirages.html b/bda/templates/bda/revente/tirages.html new file mode 100644 index 00000000..b7017806 --- /dev/null +++ b/bda/templates/bda/revente/tirages.html @@ -0,0 +1,52 @@ +{% extends "base_title.html" %} +{% load bootstrap %} + +{% block realcontent %} + +

Tirages au sort de reventes

+ +{% if annulform.reventes %} +

Les reventes auxquelles vous êtes inscrit·e

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

Tirages en cours

+
+
+ + Vous pouvez vous inscrire aux tirage en cours suivants. +
+
+ {% csrf_token %} + {{ subform|bootstrap }} +
+
+ +
+
+{% endif %} + +{% endblock %} 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/templates/bda/reventes.html b/bda/templates/bda/reventes.html deleted file mode 100644 index 0912babb..00000000 --- a/bda/templates/bda/reventes.html +++ /dev/null @@ -1,56 +0,0 @@ -{% extends "base_title.html" %} -{% load bootstrap %} - -{% block realcontent %} - -

Revente de place

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

Places non revendues

-
- {% csrf_token %} - {{resellform|bootstrap}} -
- -
-
-{% endif %} -
-{% if annul_attributions or overdue %} -

Places en cours de revente

-
- {% csrf_token %} -
-
-
    - {% for attrib in annul_attributions %} -
  • {{attrib.tag}} {{attrib.choice_label}}
  • - {% endfor %} - {% for attrib in overdue %} -
  • - - {{attrib.spectacle}} -
  • - {% endfor %} - {% if annul_attributions %} - - {% endif %} - -{% endif %} -
    -{% if sold_attributions %} -

    Places revendues

    -
    - {% csrf_token %} - {{soldform|bootstrap}} - - -
    -{% endif %} -{% if not resell_attributions and not annul_attributions and not overdue and not sold_attributions %} -

    Plus de reventes possibles !

    -{% endif %} - -{% endwith %} -{% endblock %} diff --git a/bda/tests/__init__.py b/bda/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bda/tests/test_models.py b/bda/tests/test_models.py new file mode 100644 index 00000000..95ce8646 --- /dev/null +++ b/bda/tests/test_models.py @@ -0,0 +1,100 @@ +from datetime import timedelta +from unittest import mock + +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import TestCase +from django.utils import timezone + +from bda.models import ( + Attribution, Participant, Salle, Spectacle, SpectacleRevente, Tirage, +) + +User = get_user_model() + + +class SpectacleReventeTests(TestCase): + fixtures = ['gestioncof/management/data/custommail.json'] + + def setUp(self): + now = timezone.now() + + self.t = Tirage.objects.create( + title='Tirage', + ouverture=now - timedelta(days=7), + fermeture=now - timedelta(days=3), + active=True, + ) + self.s = Spectacle.objects.create( + title='Spectacle', + date=now + timedelta(days=20), + location=Salle.objects.create(name='Salle', address='Address'), + price=10.5, + slots=5, + tirage=self.t, + listing=False, + ) + + self.seller = Participant.objects.create( + user=User.objects.create( + username='seller', email='seller@mail.net'), + tirage=self.t, + ) + self.p1 = Participant.objects.create( + user=User.objects.create(username='part1', email='part1@mail.net'), + tirage=self.t, + ) + self.p2 = Participant.objects.create( + user=User.objects.create(username='part2', email='part2@mail.net'), + tirage=self.t, + ) + self.p3 = Participant.objects.create( + user=User.objects.create(username='part3', email='part3@mail.net'), + tirage=self.t, + ) + + self.attr = Attribution.objects.create( + participant=self.seller, + spectacle=self.s, + ) + + self.rev = SpectacleRevente.objects.create( + attribution=self.attr, + seller=self.seller, + ) + + def test_tirage(self): + revente = self.rev + + wanted_by = [self.p1, self.p2, self.p3] + revente.confirmed_entry = wanted_by + + with mock.patch('bda.models.random.choice') as mc: + # Set winner to self.p1. + mc.return_value = self.p1 + + revente.tirage() + + # Call to random.choice used participants in wanted_by. + mc_args, _ = mc.call_args + + self.assertEqual(set(mc_args[0]), set(wanted_by)) + + self.assertEqual(revente.soldTo, self.p1) + self.assertTrue(revente.tirage_done) + + mails = {m.to[0]: m for m in mail.outbox} + + self.assertEqual(len(mails), 4) + + m_seller = mails['seller@mail.net'] + self.assertListEqual(m_seller.to, ['seller@mail.net']) + self.assertListEqual(m_seller.reply_to, ['part1@mail.net']) + + m_winner = mails['part1@mail.net'] + self.assertListEqual(m_winner.to, ['part1@mail.net']) + + self.assertCountEqual( + [mails['part2@mail.net'].to, mails['part3@mail.net'].to], + [['part2@mail.net'], ['part3@mail.net']], + ) 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)) diff --git a/bda/tests.py b/bda/tests/test_views.py similarity index 68% rename from bda/tests.py rename to bda/tests/test_views.py index 2741084f..88dfd980 100644 --- a/bda/tests.py +++ b/bda/tests/test_views.py @@ -1,9 +1,10 @@ import json +from django.contrib.auth.models import User from django.test import TestCase, Client from django.utils import timezone -from .models import Tirage, Spectacle, Salle, CategorieSpectacle +from bda.models import Tirage, Spectacle, Salle, CategorieSpectacle class TestBdAViews(TestCase): @@ -34,11 +35,36 @@ class TestBdAViews(TestCase): ), ]) + self.bda_user = User.objects.create_user( + username="bda_user", password="bda4ever" + ) + self.bda_user.profile.is_cof = True + self.bda_user.profile.is_buro = True + self.bda_user.profile.save() + + def bda_participants(self): + """The BdA participants views can be queried""" + client = Client() + show = self.tirage.spectacle_set.first() + + client.login(self.bda_user.username, "bda4ever") + tirage_resp = client.get("/bda/spectacles/{}".format(self.tirage.id)) + show_resp = client.get( + "/bda/spectacles/{}/{}".format(self.tirage.id, show.id) + ) + reminder_url = "/bda/mails-rappel/{}".format(show.id) + reminder_get_resp = client.get(reminder_url) + reminder_post_resp = client.post(reminder_url) + self.assertEqual(200, tirage_resp.status_code) + self.assertEqual(200, show_resp.status_code) + self.assertEqual(200, reminder_get_resp.status_code) + self.assertEqual(200, reminder_post_resp.status_code) + def test_catalogue(self): """Test the catalogue JSON API""" client = Client() - # The `list` hooh + # The `list` hook resp = client.get("/bda/catalogue/list") self.assertJSONEqual( resp.content.decode("utf-8"), diff --git a/bda/urls.py b/bda/urls.py index df660740..7264d7b3 100644 --- a/bda/urls.py +++ b/bda/urls.py @@ -1,9 +1,3 @@ -# -*- coding: utf-8 -*- - -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 @@ -16,9 +10,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,19 +23,38 @@ urlpatterns = [ url(r'^spectacles/unpaid/(?P\d+)$', views.unpaid, name="bda-unpaid"), - url(r'^liste-revente/(?P\d+)$', - views.list_revente, - name="bda-liste-revente"), - url(r'^buy-revente/(?P\d+)$', - views.buy_revente, - name="bda-buy-revente"), - url(r'^revente-interested/(?P\d+)$', - views.revente_interested, - name='bda-revente-interested'), - url(r'^revente-immediat/(?P\d+)$', + url(r'^spectacles/autocomplete$', + views.spectacle_autocomplete, + name="bda-spectacle-autocomplete"), + url(r'^participants/autocomplete$', + views.participant_autocomplete, + name="bda-participant-autocomplete"), + + # Urls BdA-Revente + + url(r'^revente/(?P\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.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"), - url(r'^mails-rappel/(?P\d+)$', views.send_rappel), + name="bda-revente-shotgun"), + + url(r'^mails-rappel/(?P\d+)$', + views.send_rappel, + name="bda-rappels" + ), url(r'^descriptions/(?P\d+)$', views.descriptions_spectacles, name='bda-descriptions'), url(r'^catalogue/(?P[a-z]+)$', views.catalogue, diff --git a/bda/views.py b/bda/views.py index 00a1b300..b49eb030 100644 --- a/bda/views.py +++ b/bda/views.py @@ -1,21 +1,17 @@ -# -*- coding: utf-8 -*- - from collections import defaultdict -from functools import partial import random import hashlib import time import json -from datetime import timedelta -from custommail.shortcuts import ( - send_mass_custom_mail, send_custom_mail, render_custom_mail -) +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 from django.contrib.auth.decorators import login_required 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 @@ -27,14 +23,16 @@ from django.views.generic.list import ListView from gestioncof.decorators import cof_required, buro_required from bda.models import ( Spectacle, Participant, ChoixSpectacle, Attribution, Tirage, - SpectacleRevente, Salle, Quote, CategorieSpectacle + SpectacleRevente, Salle, CategorieSpectacle ) from bda.algorithm import Algorithm from bda.forms import ( TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, SoldForm, - InscriptionInlineFormSet, + InscriptionInlineFormSet, ReventeTirageForm, ReventeTirageAnnulForm ) +from utils.views.autocomplete import Select2QuerySetView + @cof_required def etat_places(request, tirage_id): @@ -305,7 +303,8 @@ def do_tirage(tirage_elt, token): # On inscrit à BdA-Revente ceux qui n'ont pas eu les places voulues ChoixRevente = Participant.choicesrevente.through - # Suppression des reventes demandées/enregistrées (si le tirage est relancé) + # Suppression des reventes demandées/enregistrées + # (si le tirage est relancé) ( ChoixRevente.objects .filter(spectacle__tirage=tirage_elt) @@ -350,13 +349,21 @@ def tirage(request, tirage_id): @login_required -def revente(request, tirage_id): +def revente_manage(request, tirage_id): + """ + Gestion de ses propres reventes : + - Création d'une revente + - Annulation d'une revente + - Confirmation d'une revente = transfert de la place à la personne qui + rachète + - Annulation d'une revente après que le tirage a eu lieu + """ tirage = get_object_or_404(Tirage, id=tirage_id) participant, created = Participant.objects.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') @@ -376,12 +383,8 @@ def revente(request, tirage_id): attribution=attribution, defaults={'seller': participant}) if not created: - revente.seller = participant - revente.date = timezone.now() - revente.soldTo = None - revente.notif_sent = False - revente.tirage_done = False - revente.shotgun = False + revente.reset() + context = { 'vendeur': participant.user, 'show': attribution.spectacle, @@ -398,18 +401,18 @@ def revente(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 revente in reventes: + revente.attribution.participant = revente.soldTo + revente.attribution.save() # On annule la revente après le tirage au sort (par exemple si # la personne qui a gagné le tirage ne se manifeste pas). La place est @@ -417,18 +420,13 @@ def revente(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(): - 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.answered_mail: - revente.answered_mail.clear() - revente.save() + reventes = soldform.cleaned_data['reventes'] + for revente in reventes: + if revente.attribution.spectacle.date > timezone.now(): + # On antidate pour envoyer le mail plus vite + new_date = (timezone.now() + - SpectacleRevente.remorse_time) + revente.reset(new_date=new_date) overdue = participant.attribution_set.filter( spectacle__date__gte=timezone.now(), @@ -438,28 +436,80 @@ 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}) @login_required -def revente_interested(request, revente_id): +def revente_tirages(request, tirage_id): + """ + Affiche à un participant la liste de toutes les reventes en cours (pour un + tirage donné) et lui permet de s'inscrire et se désinscrire à ces reventes. + """ + tirage = get_object_or_404(Tirage, id=tirage_id) + participant, _ = Participant.objects.get_or_create( + user=request.user, tirage=tirage) + subform = ReventeTirageForm(participant, prefix="subscribe") + annulform = ReventeTirageAnnulForm(participant, prefix="annul") + + if request.method == 'POST': + if "subscribe" in request.POST: + subform = ReventeTirageForm(participant, request.POST, + prefix="subscribe") + if subform.is_valid(): + reventes = subform.cleaned_data['reventes'] + count = reventes.count() + for revente in reventes: + revente.confirmed_entry.add(participant) + if count > 0: + messages.success( + request, + "Tu as bien été inscrit à {} revente{}" + .format(count, pluralize(count)) + ) + elif "annul" in request.POST: + annulform = ReventeTirageAnnulForm(participant, request.POST, + prefix="annul") + if annulform.is_valid(): + reventes = annulform.cleaned_data['reventes'] + count = reventes.count() + for revente in reventes: + revente.confirmed_entry.remove(participant) + if count > 0: + messages.success( + request, + "Tu as bien été désinscrit de {} revente{}" + .format(count, pluralize(count)) + ) + + return render(request, "bda/revente/tirages.html", + {"annulform": annulform, "subform": subform}) + + +@login_required +def revente_confirm(request, revente_id): revente = get_object_or_404(SpectacleRevente, id=revente_id) participant, _ = Participant.objects.get_or_create( user=request.user, tirage=revente.attribution.spectacle.tirage) - if (timezone.now() < revente.date + timedelta(hours=1)) or revente.shotgun: - return render(request, "bda-wrongtime.html", + if not revente.notif_sent or revente.shotgun: + return render(request, "bda/revente/wrongtime.html", {"revente": revente}) - revente.answered_mail.add(participant) - return render(request, "bda-interested.html", + revente.confirmed_entry.add(participant) + 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): + """ + Permet à un participant de sélectionner ses préférences pour les reventes. + Il recevra des notifications pour les spectacles qui l'intéressent et il + est automatiquement inscrit aux reventes en cours au moment où il ajoute un + spectacle à la liste des spectacles qui l'intéressent. + """ tirage = get_object_or_404(Tirage, id=tirage_id) participant, _ = Participant.objects.get_or_create( user=request.user, tirage=tirage) @@ -485,12 +535,12 @@ def list_revente(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: @@ -513,11 +563,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( @@ -531,13 +581,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) @@ -554,11 +604,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}) @@ -582,7 +632,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}) @@ -612,7 +662,7 @@ def spectacle(request, tirage_id, spectacle_id): participants_info = sorted(participants.values(), key=lambda part: part['lastname']) - return render(request, "bda-participants.html", + return render(request, "bda/participants.html", {"spectacle": spectacle, "participants": participants_info}) @@ -629,7 +679,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 @@ -651,20 +701,24 @@ def unpaid(request, tirage_id): def send_rappel(request, spectacle_id): show = get_object_or_404(Spectacle, id=spectacle_id) # Mails d'exemples - exemple_mail_1place = render_custom_mail('bda-rappel', { + custommail = CustomMail.objects.get(shortname="bda-rappel") + exemple_mail_1place = custommail.render({ 'member': request.user, 'show': show, 'nb_attr': 1 }) - exemple_mail_2places = render_custom_mail('bda-rappel', { + exemple_mail_2places = custommail.render({ 'member': request.user, 'show': show, 'nb_attr': 2 }) # Contexte - ctxt = {'show': show, - 'exemple_mail_1place': exemple_mail_1place, - 'exemple_mail_2places': exemple_mail_2places} + ctxt = { + 'show': show, + 'exemple_mail_1place': exemple_mail_1place, + 'exemple_mail_2places': exemple_mail_2places, + 'custommail': custommail, + } # Envoi confirmé if request.method == 'POST': members = show.send_rappel() @@ -673,6 +727,14 @@ def send_rappel(request, spectacle_id): # Demande de confirmation else: ctxt['sent'] = False + if show.rappel_sent: + messages.warning( + request, + "Attention, un mail de rappel pour ce spectale a déjà été " + "envoyé le {}".format(formats.localize( + timezone.template_localtime(show.rappel_sent) + )) + ) return render(request, "bda/mails-rappel.html", ctxt) @@ -771,9 +833,9 @@ def catalogue(request, request_type): .select_related('location') .prefetch_related('quote_set') ) - if categories_id: + if categories_id and 0 not in categories_id: shows_qs = shows_qs.filter(category__id__in=categories_id) - if locations_id: + if locations_id and 0 not in locations_id: shows_qs = shows_qs.filter(location__id__in=locations_id) # On convertit les descriptions à envoyer en une liste facilement @@ -802,3 +864,26 @@ def catalogue(request, request_type): return JsonResponse(data_return, safe=False) # Si la requête n'est pas de la forme attendue, on quitte avec une erreur return HttpResponseBadRequest() + + +## +# Autocomplete views +# +# https://django-autocomplete-light.readthedocs.io/en/master/tutorial.html#create-an-autocomplete-view +## + + +class ParticipantAutocomplete(Select2QuerySetView): + model = Participant + search_fields = ('user__username', 'user__first_name', 'user__last_name') + + +participant_autocomplete = buro_required(ParticipantAutocomplete.as_view()) + + +class SpectacleAutocomplete(Select2QuerySetView): + model = Spectacle + search_fields = ('title',) + + +spectacle_autocomplete = buro_required(SpectacleAutocomplete.as_view()) diff --git a/cof/locale/fr/formats.py b/cof/locale/fr/formats.py index 710fa6ed..4b47ce3d 100644 --- a/cof/locale/fr/formats.py +++ b/cof/locale/fr/formats.py @@ -1,9 +1,5 @@ -# -*- encoding: utf-8 -*- - """ Formats français. """ -from __future__ import unicode_literals - DATETIME_FORMAT = r'l j F Y \à H:i' diff --git a/cof/routing.py b/cof/routing.py index 7949ee3a..c604adf4 100644 --- a/cof/routing.py +++ b/cof/routing.py @@ -1,3 +1,6 @@ -from kfet.routing import channel_routing as kfet_channel_routings +from channels.routing import include -channel_routing = kfet_channel_routings + +routing = [ + include('kfet.routing.routing', path=r'^/ws/k-fet'), +] diff --git a/cof/settings/__init__.py b/cof/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cof/settings/common.py b/cof/settings/common.py index 261760d6..02c796ad 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Django common settings for cof project. @@ -7,68 +6,117 @@ the local development server should be here. """ import os +import sys -# Database credentials try: - from .secret import DBNAME, DBUSER, DBPASSWD + from . import secret except ImportError: - # On the local development VM, theses credentials are in the environment - DBNAME = os.environ["DBNAME"] - DBUSER = os.environ["DBUSER"] - DBPASSWD = os.environ["DBPASSWD"] -except KeyError: - raise RuntimeError("Secrets missing") - -# Other secrets -try: - from .secret import ( - SECRET_KEY, RECAPTCHA_PUBLIC_KEY, RECAPTCHA_PRIVATE_KEY, ADMINS, - REDIS_PASSWD, REDIS_DB, REDIS_HOST, REDIS_PORT + raise ImportError( + "The secret.py file is missing.\n" + "For a development environment, simply copy secret_example.py" ) -except ImportError: - raise RuntimeError("Secrets missing") + + +def import_secret(name): + """ + Shorthand for importing a value from the secret module and raising an + informative exception if a secret is missing. + """ + try: + return getattr(secret, name) + except AttributeError: + raise RuntimeError("Secret missing: {}".format(name)) + + +SECRET_KEY = import_secret("SECRET_KEY") +ADMINS = import_secret("ADMINS") +SERVER_EMAIL = import_secret("SERVER_EMAIL") +EMAIL_HOST = import_secret("EMAIL_HOST") + +DBNAME = import_secret("DBNAME") +DBUSER = import_secret("DBUSER") +DBPASSWD = import_secret("DBPASSWD") + +REDIS_PASSWD = import_secret("REDIS_PASSWD") +REDIS_DB = import_secret("REDIS_DB") +REDIS_HOST = import_secret("REDIS_HOST") +REDIS_PORT = import_secret("REDIS_PORT") + +KFETOPEN_TOKEN = import_secret("KFETOPEN_TOKEN") +LDAP_SERVER_URL = import_secret("LDAP_SERVER_URL") BASE_DIR = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) +TESTING = sys.argv[1] == 'test' + # Application definition INSTALLED_APPS = [ + 'shared', + 'gestioncof', + + # Must be before 'django.contrib.admin'. + # https://django-autocomplete-light.readthedocs.io/en/master/install.html + 'dal', + 'dal_select2', + 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', 'django.contrib.staticfiles', - 'grappelli', 'django.contrib.admin', 'django.contrib.admindocs', + 'bda', - 'autocomplete_light', 'captcha', 'django_cas_ng', 'bootstrapform', 'kfet', + 'kfet.open', 'channels', 'widget_tweaks', 'custommail', 'djconfig', + 'wagtail.wagtailforms', + 'wagtail.wagtailredirects', + 'wagtail.wagtailembeds', + 'wagtail.wagtailsites', + 'wagtail.wagtailusers', + 'wagtail.wagtailsnippets', + 'wagtail.wagtaildocs', + 'wagtail.wagtailimages', + 'wagtail.wagtailsearch', + 'wagtail.wagtailadmin', + 'wagtail.wagtailcore', + 'wagtail.contrib.modeladmin', + 'wagtailmenus', + 'modelcluster', + 'taggit', + 'kfet.auth', + 'kfet.cms', + 'corsheaders', ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', - 'kfet.middleware.KFetAuthenticationMiddleware', + 'kfet.auth.middleware.TemporaryAuthMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', 'djconfig.middleware.DjConfigMiddleware', + 'wagtail.wagtailcore.middleware.SiteMiddleware', + 'wagtail.wagtailredirects.middleware.RedirectMiddleware', ] ROOT_URLCONF = 'cof.urls' @@ -84,12 +132,13 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - 'django.core.context_processors.i18n', - 'django.core.context_processors.media', - 'django.core.context_processors.static', + 'django.template.context_processors.i18n', + 'django.template.context_processors.media', + 'django.template.context_processors.static', + 'wagtailmenus.context_processors.wagtailmenus', 'djconfig.context_processors.config', 'gestioncof.shared.context_processor', - 'kfet.context_processors.auth', + 'kfet.auth.context_processors.temporary_auth', 'kfet.context_processors.config', ], }, @@ -98,7 +147,7 @@ TEMPLATES = [ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.mysql', + 'ENGINE': 'django.db.backends.postgresql_psycopg2', 'NAME': DBNAME, 'USER': DBUSER, 'PASSWORD': DBPASSWD, @@ -143,17 +192,47 @@ LOGIN_URL = "cof-login" LOGIN_REDIRECT_URL = "home" CAS_SERVER_URL = 'https://cas.eleves.ens.fr/' +CAS_VERSION = '3' +CAS_LOGIN_MSG = None CAS_IGNORE_REFERER = True CAS_REDIRECT_URL = '/' CAS_EMAIL_FORMAT = "%s@clipper.ens.fr" + AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', 'gestioncof.shared.COFCASBackend', - 'kfet.backends.GenericTeamBackend', + 'kfet.auth.backends.GenericBackend', ) + +# reCAPTCHA settings +# https://github.com/praekelt/django-recaptcha +# +# Default settings authorize reCAPTCHA usage for local developement. +# Public and private keys are appended in the 'prod' module settings. + +NOCAPTCHA = True RECAPTCHA_USE_SSL = True +CORS_ORIGIN_WHITELIST = ( + 'bda.ens.fr', + 'www.bda.ens.fr' + 'cof.ens.fr', + 'www.cof.ens.fr', +) + +# Cache settings + +CACHES = { + 'default': { + 'BACKEND': 'redis_cache.RedisCache', + 'LOCATION': 'redis://:{passwd}@{host}:{port}/db' + .format(passwd=REDIS_PASSWD, host=REDIS_HOST, + port=REDIS_PORT, db=REDIS_DB), + } +} + + # Channels settings CHANNEL_LAYERS = { @@ -166,8 +245,14 @@ CHANNEL_LAYERS = { port=REDIS_PORT, db=REDIS_DB) )], }, - "ROUTING": "cof.routing.channel_routing", + "ROUTING": "cof.routing.routing", } } FORMAT_MODULE_PATH = 'cof.locale' + +# Wagtail settings + +WAGTAIL_SITE_NAME = 'GestioCOF' +WAGTAIL_ENABLE_UPDATE_CHECK = False +TAGGIT_CASE_INSENSITIVE = True diff --git a/cof/settings/dev.py b/cof/settings/dev.py index a3a17f99..114f37da 100644 --- a/cof/settings/dev.py +++ b/cof/settings/dev.py @@ -3,24 +3,28 @@ Django development settings for the cof project. The settings that are not listed here are imported from .common """ -import os - -from .common import * +from .common import * # NOQA +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 # --- STATIC_URL = '/static/' -STATIC_ROOT = '/var/www/static/' +STATIC_ROOT = '/srv/gestiocof/static/' -MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') +MEDIA_ROOT = '/srv/gestiocof/media/' MEDIA_URL = '/media/' @@ -37,11 +41,13 @@ def show_toolbar(request): """ return DEBUG -INSTALLED_APPS += ["debug_toolbar", "debug_panel"] -MIDDLEWARE_CLASSES = ( - ["debug_panel.middleware.DebugPanelMiddleware"] - + MIDDLEWARE_CLASSES -) -DEBUG_TOOLBAR_CONFIG = { - 'SHOW_TOOLBAR_CALLBACK': show_toolbar, -} +if not TESTING: + INSTALLED_APPS += ["debug_toolbar", "debug_panel"] + + MIDDLEWARE = [ + "debug_panel.middleware.DebugPanelMiddleware" + ] + MIDDLEWARE + + DEBUG_TOOLBAR_CONFIG = { + 'SHOW_TOOLBAR_CALLBACK': show_toolbar, + } diff --git a/cof/settings/local.py b/cof/settings/local.py new file mode 100644 index 00000000..6e1f0802 --- /dev/null +++ b/cof/settings/local.py @@ -0,0 +1,36 @@ +""" +Django local settings for the cof project. +The settings that are not listed here are imported from .common +""" + +import os + +from .dev import * # NOQA +from .dev import BASE_DIR + + +# Use sqlite for local development +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3") + } +} + +# Use the default cache backend for local development +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache" + } +} + +# Use the default in memory asgi backend for local development +CHANNEL_LAYERS = { + "default": { + "BACKEND": "asgiref.inmemory.ChannelLayer", + "ROUTING": "cof.routing.routing", + } +} + +# No need to run collectstatic -> unset STATIC_ROOT +STATIC_ROOT = None diff --git a/cof/settings/prod.py b/cof/settings/prod.py index 5fae5651..fcdb3fdb 100644 --- a/cof/settings/prod.py +++ b/cof/settings/prod.py @@ -5,7 +5,8 @@ The settings that are not listed here are imported from .common import os -from .common import * +from .common import * # NOQA +from .common import BASE_DIR, import_secret DEBUG = False @@ -16,11 +17,18 @@ ALLOWED_HOSTS = [ "dev.cof.ens.fr" ] -STATIC_ROOT = os.path.join(os.path.dirname(BASE_DIR), "static") + +STATIC_ROOT = os.path.join( + os.path.dirname(os.path.dirname(BASE_DIR)), + "public", + "gestion", + "static", +) + STATIC_URL = "/gestion/static/" MEDIA_ROOT = os.path.join(os.path.dirname(BASE_DIR), "media") MEDIA_URL = "/gestion/media/" -LDAP_SERVER_URL = "ldaps://ldap.spi.ens.fr:636" -EMAIL_HOST = "nef.ens.fr" +RECAPTCHA_PUBLIC_KEY = import_secret("RECAPTCHA_PUBLIC_KEY") +RECAPTCHA_PRIVATE_KEY = import_secret("RECAPTCHA_PRIVATE_KEY") diff --git a/cof/settings/secret_example.py b/cof/settings/secret_example.py index eeb5271c..e966565a 100644 --- a/cof/settings/secret_example.py +++ b/cof/settings/secret_example.py @@ -1,8 +1,21 @@ SECRET_KEY = 'q()(zn4m63i%5cp4)f+ww4-28_w+ly3q9=6imw2ciu&_(5_4ah' -RECAPTCHA_PUBLIC_KEY = "DUMMY" -RECAPTCHA_PRIVATE_KEY = "DUMMY" +ADMINS = None +SERVER_EMAIL = "root@vagrant" +EMAIL_HOST = "localhost" + +DBUSER = "cof_gestion" +DBNAME = "cof_gestion" +DBPASSWD = "4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4" + REDIS_PASSWD = "dummy" REDIS_PORT = 6379 REDIS_DB = 0 REDIS_HOST = "127.0.0.1" -ADMINS = None + +RECAPTCHA_PUBLIC_KEY = "DUMMY" +RECAPTCHA_PRIVATE_KEY = "DUMMY" + +EMAIL_HOST = None + +KFETOPEN_TOKEN = "plop" +LDAP_SERVER_URL = None diff --git a/cof/urls.py b/cof/urls.py index 06b1087a..a1d0c9bf 100644 --- a/cof/urls.py +++ b/cof/urls.py @@ -1,11 +1,7 @@ -# -*- coding: utf-8 -*- - """ Fichier principal de configuration des urls du projet GestioCOF """ -import autocomplete_light - from django.conf import settings from django.conf.urls import include, url from django.conf.urls.static import static @@ -14,13 +10,16 @@ from django.views.generic.base import TemplateView from django.contrib.auth import views as django_views from django_cas_ng import views as django_cas_views +from wagtail.wagtailadmin import urls as wagtailadmin_urls +from wagtail.wagtailcore import urls as wagtail_urls +from wagtail.wagtaildocs import urls as wagtaildocs_urls + from gestioncof import views as gestioncof_views, csv_views from gestioncof.urls import export_patterns, petitcours_patterns, \ surveys_patterns, events_patterns, calendar_patterns, \ clubs_patterns from gestioncof.autocomplete import autocomplete -autocomplete_light.autodiscover() admin.autodiscover() urlpatterns = [ @@ -45,18 +44,22 @@ urlpatterns = [ name="cof-denied"), url(r'^cas/login$', django_cas_views.login, name="cas_login_view"), url(r'^cas/logout$', django_cas_views.logout), - url(r'^outsider/login$', gestioncof_views.login_ext), + url(r'^outsider/login$', gestioncof_views.login_ext, + name="ext_login_view"), url(r'^outsider/logout$', django_views.logout, {'next_page': 'home'}), url(r'^login$', gestioncof_views.login, name="cof-login"), - url(r'^logout$', gestioncof_views.logout), + url(r'^logout$', gestioncof_views.logout, name="cof-logout"), # Infos persos - url(r'^profile$', gestioncof_views.profile), - url(r'^outsider/password-change$', django_views.password_change), + url(r'^profile$', gestioncof_views.profile, + name='profile'), + url(r'^outsider/password-change$', django_views.password_change, + name='password_change'), url(r'^outsider/password-change-done$', django_views.password_change_done, name='password_change_done'), # Inscription d'un nouveau membre - url(r'^registration$', gestioncof_views.registration), + url(r'^registration$', gestioncof_views.registration, + name='registration'), url(r'^registration/clipper/(?P[\w-]+)/' r'(?P.*)$', gestioncof_views.registration_form2, name="clipper-registration"), @@ -65,8 +68,10 @@ urlpatterns = [ url(r'^registration/empty$', gestioncof_views.registration_form2, name="empty-registration"), # Autocompletion - url(r'^autocomplete/registration$', autocomplete), - url(r'^autocomplete/', include('autocomplete_light.urls')), + url(r'^autocomplete/registration$', autocomplete, + name="cof.registration.autocomplete"), + url(r'^user/autocomplete$', gestioncof_views.user_autocomplete, + name='cof-user-autocomplete'), # Interface admin url(r'^admin/logout/', gestioncof_views.logout), url(r'^admin/doc/', include('django.contrib.admindocs.urls')), @@ -74,14 +79,23 @@ urlpatterns = [ csv_views.admin_list_export, {'fields': ['username', ]}), url(r'^admin/', include(admin.site.urls)), - url(r'^grappelli/', include('grappelli.urls')), # Liens utiles du COF et du BdA - url(r'^utile_cof$', gestioncof_views.utile_cof), - url(r'^utile_bda$', gestioncof_views.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_bda/bda_revente$', gestioncof_views.liste_bdarevente), + url(r'^utile_cof$', gestioncof_views.utile_cof, + name='utile_cof'), + url(r'^utile_bda$', gestioncof_views.utile_bda, + name='utile_bda'), + url(r'^utile_bda/bda_diff$', gestioncof_views.liste_bdadiff, + name="ml_diffbda"), + url(r'^utile_cof/diff_cof$', gestioncof_views.liste_diffcof, + name='ml_diffcof'), + url(r'^utile_bda/bda_revente$', gestioncof_views.liste_bdarevente, + name="ml_bda_revente"), 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(), + name='config.edit'), ] if 'debug_toolbar' in settings.INSTALLED_APPS: @@ -90,7 +104,13 @@ if 'debug_toolbar' in settings.INSTALLED_APPS: url(r'^__debug__/', include(debug_toolbar.urls)), ] +if settings.DEBUG: # Si on est en production, MEDIA_ROOT est servi par Apache. # Il faut dire à Django de servir MEDIA_ROOT lui-même en développement. urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + +# Wagtail for uncatched +urlpatterns += [ + url(r'', include(wagtail_urls)), +] diff --git a/gestioncof/__init__.py b/gestioncof/__init__.py index e69de29b..b77fdb94 100644 --- a/gestioncof/__init__.py +++ b/gestioncof/__init__.py @@ -0,0 +1 @@ +default_app_config = 'gestioncof.apps.GestioncofConfig' diff --git a/gestioncof/admin.py b/gestioncof/admin.py index a177c26c..54a6a5a0 100644 --- a/gestioncof/admin.py +++ b/gestioncof/admin.py @@ -1,9 +1,3 @@ -# -*- coding: utf-8 -*- - -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - from django import forms from django.contrib import admin from django.utils.translation import ugettext_lazy as _ @@ -18,13 +12,12 @@ from django.contrib.auth.admin import UserAdmin from django.core.urlresolvers import reverse from django.utils.safestring import mark_safe from django.db.models import Q -import django.utils.six as six -import autocomplete_light +from dal.autocomplete import ModelSelect2 -def add_link_field(target_model='', field='', link_text=six.text_type, - desc_text=six.text_type): +def add_link_field(target_model='', field='', link_text=str, + desc_text=str): def add_link(cls): reverse_name = target_model or cls.model.__name__.lower() @@ -139,7 +132,6 @@ def ProfileInfo(field, short_description, boolean=False): User.profile_login_clipper = FkeyLookup("profile__login_clipper", "Login clipper") -User.profile_num = FkeyLookup("profile__num", "Numéro") User.profile_phone = ProfileInfo("phone", "Téléphone") User.profile_occupation = ProfileInfo("occupation", "Occupation") User.profile_departement = ProfileInfo("departement", "Departement") @@ -166,10 +158,12 @@ class UserProfileAdmin(UserAdmin): is_cof.short_description = 'Membre du COF' is_cof.boolean = True - list_display = ('profile_num',) + UserAdmin.list_display \ + list_display = ( + UserAdmin.list_display + ('profile_login_clipper', 'profile_phone', 'profile_occupation', 'profile_mailing_cof', 'profile_mailing_bda', 'profile_mailing_bda_revente', 'is_cof', 'is_buro', ) + ) list_display_links = ('username', 'email', 'first_name', 'last_name') list_filter = UserAdmin.list_filter \ + ('profile__is_cof', 'profile__is_buro', 'profile__mailing_cof', @@ -187,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') @@ -215,21 +209,25 @@ class UserProfileAdmin(UserAdmin): # FIXME: This is absolutely horrible. -def user_unicode(self): +def user_str(self): if self.first_name and self.last_name: - return "%s %s (%s)" % (self.first_name, self.last_name, self.username) + return "{} ({})".format(self.get_full_name(), self.username) else: return self.username -if six.PY2: - User.__unicode__ = user_unicode -else: - User.__str__ = user_unicode +User.__str__ = user_str + + +class EventRegistrationAdminForm(forms.ModelForm): + class Meta: + widgets = { + 'user': ModelSelect2(url='cof-user-autocomplete'), + } class EventRegistrationAdmin(admin.ModelAdmin): - form = autocomplete_light.modelform_factory(EventRegistration, exclude=[]) - list_display = ('__unicode__' if six.PY2 else '__str__', 'event', 'user', - 'paid') + form = EventRegistrationAdminForm + + list_display = ('__str__', 'event', 'user', 'paid') list_filter = ('paid',) search_fields = ('user__username', 'user__first_name', 'user__last_name', 'user__email', 'event__title') @@ -269,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/apps.py b/gestioncof/apps.py new file mode 100644 index 00000000..78120ef4 --- /dev/null +++ b/gestioncof/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig + + +class GestioncofConfig(AppConfig): + name = 'gestioncof' + verbose_name = "Gestion des adhérents du COF" + + def ready(self): + from . import signals + self.register_config() + + def register_config(self): + import djconfig + from .forms import GestioncofConfigForm + djconfig.register(GestioncofConfigForm) diff --git a/gestioncof/autocomplete.py b/gestioncof/autocomplete.py index 3532525d..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 @@ -58,7 +56,7 @@ def autocomplete(request): ) # Fetching data from the SPI - if hasattr(settings, 'LDAP_SERVER_URL'): + if getattr(settings, 'LDAP_SERVER_URL', None): # Fetching ldap_query = '(&{:s})'.format(''.join( '(|(cn=*{bit:s}*)(uid=*{bit:s}*))'.format(bit=bit) diff --git a/gestioncof/autocomplete_light_registry.py b/gestioncof/autocomplete_light_registry.py deleted file mode 100644 index 4c62d995..00000000 --- a/gestioncof/autocomplete_light_registry.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- - -import autocomplete_light - -from django.contrib.auth.models import User - -autocomplete_light.register( - User, search_fields=('username', 'first_name', 'last_name'), - attrs={'placeholder': 'membre...'} -) diff --git a/gestioncof/csv_views.py b/gestioncof/csv_views.py index c1d82aca..733768dc 100644 --- a/gestioncof/csv_views.py +++ b/gestioncof/csv_views.py @@ -1,9 +1,3 @@ -# -*- coding: utf-8 -*- - -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/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/forms.py b/gestioncof/forms.py index 3a519a39..4a9087fd 100644 --- a/gestioncof/forms.py +++ b/gestioncof/forms.py @@ -1,21 +1,14 @@ -# -*- coding: utf-8 -*- - -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - from django import forms from django.utils.translation import ugettext_lazy as _ from django.contrib.auth.models import User from django.forms.widgets import RadioSelect, CheckboxSelectMultiple from django.forms.formsets import BaseFormSet, formset_factory -from django.db.models import Max -from django.core.validators import MinLengthValidator + +from djconfig.forms import ConfigForm from gestioncof.models import CofProfile, EventCommentValue, \ CalendarSubscription, Club from gestioncof.widgets import TriStateCheckbox -from gestioncof.shared import lock_table, unlock_table from bda.models import Spectacle @@ -25,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(): @@ -67,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(): @@ -107,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) @@ -136,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) @@ -182,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() @@ -200,12 +193,9 @@ 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 = "" - def force_long_username(self): - self.fields['username'].validators = [MinLengthValidator(9)] - class Meta: model = User fields = ("username", "first_name", "last_name", "email") @@ -229,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() @@ -239,11 +228,10 @@ 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 - self.fields['num'].widget.attrs['readonly'] = True self.fields.keyOrder = [ 'login_clipper', @@ -251,7 +239,6 @@ class RegistrationProfileForm(forms.ModelForm): 'occupation', 'departement', 'is_cof', - 'num', 'type_cotiz', 'mailing_cof', 'mailing_bda', @@ -259,24 +246,9 @@ class RegistrationProfileForm(forms.ModelForm): 'comments' ] - def save(self, *args, **kw): - instance = super(RegistrationProfileForm, self).save(*args, **kw) - if instance.is_cof and not instance.num: - # Generate new number - try: - lock_table(CofProfile) - aggregate = CofProfile.objects.aggregate(Max('num')) - instance.num = aggregate['num__max'] + 1 - instance.save() - self.cleaned_data['num'] = instance.num - self.data['num'] = instance.num - finally: - unlock_table(CofProfile) - return instance - class Meta: model = CofProfile - fields = ("login_clipper", "num", "phone", "occupation", + fields = ("login_clipper", "phone", "occupation", "departement", "is_cof", "type_cotiz", "mailing_cof", "mailing_bda", "mailing_bda_revente", "comments") @@ -301,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: @@ -364,24 +336,27 @@ 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) 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), @@ -403,3 +378,16 @@ class ClubsForm(forms.Form): queryset=Club.objects.all(), widget=forms.CheckboxSelectMultiple, required=False) + + +# --- +# Announcements banner +# TODO: move this to the `gestion` app once the supportBDS branch is merged +# --- + +class GestioncofConfigForm(ConfigForm): + gestion_banner = forms.CharField( + label=_("Announcements banner"), + help_text=_("An empty banner disables annoucements"), + max_length=2048 + ) diff --git a/gestioncof/management/commands/syncmails.py b/gestioncof/management/commands/syncmails.py index 1d3dddb8..689da716 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 """ @@ -63,8 +62,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 +79,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) + ) diff --git a/gestioncof/management/data/custommail.json b/gestioncof/management/data/custommail.json index 9ee9b1ea..691043e9 100644 --- a/gestioncof/management/data/custommail.json +++ b/gestioncof/management/data/custommail.json @@ -1,148 +1,161 @@ [ { - "model": "custommail.variabletype", - "pk": 1, + "model": "custommail.type", "fields": { + "kind": "model", "content_type": [ "auth", "user" ], "inner1": null, - "kind": "model", "inner2": null - } + }, + "pk": 1 }, { - "model": "custommail.variabletype", - "pk": 2, + "model": "custommail.type", "fields": { + "kind": "int", "content_type": null, "inner1": null, - "kind": "int", "inner2": null - } + }, + "pk": 2 }, { - "model": "custommail.variabletype", - "pk": 3, + "model": "custommail.type", "fields": { + "kind": "model", "content_type": [ "bda", "spectacle" ], "inner1": null, - "kind": "model", "inner2": null - } + }, + "pk": 3 }, { - "model": "custommail.variabletype", - "pk": 4, + "model": "custommail.type", "fields": { + "kind": "model", "content_type": [ "bda", "spectaclerevente" ], "inner1": null, - "kind": "model", "inner2": null - } + }, + "pk": 4 }, { - "model": "custommail.variabletype", - "pk": 5, + "model": "custommail.type", "fields": { + "kind": "model", "content_type": [ "sites", "site" ], "inner1": null, - "kind": "model", "inner2": null - } + }, + "pk": 5 }, { - "model": "custommail.variabletype", - "pk": 6, + "model": "custommail.type", "fields": { + "kind": "model", "content_type": [ "gestioncof", "petitcoursdemande" ], "inner1": null, - "kind": "model", "inner2": null - } + }, + "pk": 6 }, { - "model": "custommail.variabletype", - "pk": 7, + "model": "custommail.type", "fields": { - "content_type": null, - "inner1": null, "kind": "list", + "content_type": null, + "inner1": 12, "inner2": null - } + }, + "pk": 7 }, { - "model": "custommail.variabletype", - "pk": 8, + "model": "custommail.type", "fields": { + "kind": "list", "content_type": null, "inner1": 1, - "kind": "list", "inner2": null - } + }, + "pk": 8 }, { - "model": "custommail.variabletype", - "pk": 9, + "model": "custommail.type", "fields": { - "content_type": null, - "inner1": null, "kind": "pair", + "content_type": null, + "inner1": 12, "inner2": 8 - } + }, + "pk": 9 }, { - "model": "custommail.variabletype", - "pk": 10, + "model": "custommail.type", "fields": { + "kind": "list", "content_type": null, "inner1": 9, - "kind": "list", "inner2": null - } + }, + "pk": 10 }, { - "model": "custommail.variabletype", - "pk": 11, + "model": "custommail.type", "fields": { + "kind": "list", "content_type": null, "inner1": 3, - "kind": "list", "inner2": null - } + }, + "pk": 11 +}, +{ + "model": "custommail.type", + "fields": { + "kind": "model", + "content_type": [ + "gestioncof", + "petitcourssubject" + ], + "inner1": null, + "inner2": null + }, + "pk": 12 }, { "model": "custommail.custommail", - "pk": 1, "fields": { "shortname": "welcome", "subject": "Bienvenue au COF", - "description": "Mail de bienvenue au COF envoy\u00e9 automatiquement \u00e0 l'inscription d'un nouveau membre", - "body": "Bonjour {{ member.first_name }} et bienvenue au COF !\r\n\r\nTu trouveras plein de trucs cool sur le site du COF : https://www.cof.ens.fr/ et notre page Facebook : https://www.facebook.com/cof.ulm\r\nEt n'oublie pas d'aller d\u00e9couvrir GestioCOF, la plateforme de gestion du COF !\r\nSi tu as des questions, tu peux nous envoyer un mail \u00e0 cof@ens.fr (on aime le spam), ou passer nous voir au Bur\u00f4 pr\u00e8s de la Cour\u00f4 du lundi au vendredi de 12h \u00e0 14h et de 18h \u00e0 20h.\r\n\r\nRetrouvez les \u00e9v\u00e8nements de rentr\u00e9e pour les conscrit.e.s et les vieux/vieilles organis\u00e9s par le COF et ses clubs ici : http://www.cof.ens.fr/depot/Rentree.pdf \r\n\r\nAmicalement,\r\n\r\nTon COF qui t'aime." - } + "body": "Bonjour {{ member.first_name }} et bienvenue au COF !\r\n\r\nTu trouveras plein de trucs cool sur le site du COF : https://www.cof.ens.fr/ et notre page Facebook : https://www.facebook.com/cof.ulm\r\nEt n'oublie pas d'aller d\u00e9couvrir GestioCOF, la plateforme de gestion du COF !\r\nSi tu as des questions, tu peux nous envoyer un mail \u00e0 cof@ens.fr (on aime le spam), ou passer nous voir au Bur\u00f4 pr\u00e8s de la Cour\u00f4 du lundi au vendredi de 12h \u00e0 14h et de 18h \u00e0 20h.\r\n\r\nRetrouvez les \u00e9v\u00e8nements de rentr\u00e9e pour les conscrit.e.s et les vieux/vieilles organis\u00e9s par le COF et ses clubs ici : http://www.cof.ens.fr/depot/Rentree.pdf \r\n\r\nAmicalement,\r\n\r\nTon COF qui t'aime.", + "description": "Mail de bienvenue au COF envoy\u00e9 automatiquement \u00e0 l'inscription d'un nouveau membre" + }, + "pk": 1 }, { "model": "custommail.custommail", - "pk": 2, "fields": { "shortname": "bda-rappel", "subject": "{{ show }}", - "description": "Mail de rappel pour les spectacles BdA", - "body": "Bonjour {{ member.first_name }},\r\n\r\nNous te rappellons que tu as eu la chance d'obtenir {{ nb_attr|pluralize:\"une place,deux places\" }}\r\npour {{ show.title }}, le {{ show.date }} au {{ show.location }}. N'oublie pas de t'y rendre !\r\n{% if nb_attr == 2 %}\r\nTu as obtenu deux places pour ce spectacle. Nous te rappelons que\r\nces places sont strictement r\u00e9serv\u00e9es aux personnes de moins de 28 ans.\r\n{% endif %}\r\n{% if show.listing %}Pour ce spectacle, tu as re\u00e7u des places sur\r\nlisting. Il te faudra donc te rendre 15 minutes en avance sur les lieux de la repr\u00e9sentation\r\npour retirer {{ nb_attr|pluralize:\"ta place,tes places\" }}.\r\n{% else %}Pour assister \u00e0 ce spectacle, tu dois pr\u00e9senter les billets qui ont\r\n\u00e9t\u00e9 distribu\u00e9s au bur\u00f4.\r\n{% endif %}\r\n\r\nSi tu ne peux plus assister \u00e0 cette repr\u00e9sentation, tu peux\r\nrevendre ta place via BdA-revente, accessible directement sur\r\nGestioCOF (lien \"revendre une place du premier tirage\" sur la page\r\nd'accueil https://www.cof.ens.fr/gestion/).\r\n\r\nEn te souhaitant un excellent spectacle,\r\n\r\nLe Bureau des Arts" - } + "body": "Bonjour {{ member.first_name }},\r\n\r\nNous te rappellons que tu as eu la chance d'obtenir {{ nb_attr|pluralize:\"une place,deux places\" }}\r\npour {{ show.title }}, le {{ show.date }} au {{ show.location }}. N'oublie pas de t'y rendre !\r\n{% if nb_attr == 2 %}\r\nTu as obtenu deux places pour ce spectacle. Nous te rappelons que\r\nces places sont strictement r\u00e9serv\u00e9es aux personnes de moins de 28 ans.\r\n{% endif %}\r\n{% if show.listing %}Pour ce spectacle, tu as re\u00e7u des places sur\r\nlisting. Il te faudra donc te rendre 15 minutes en avance sur les lieux de la repr\u00e9sentation\r\npour retirer {{ nb_attr|pluralize:\"ta place,tes places\" }}.\r\n{% else %}Pour assister \u00e0 ce spectacle, tu dois pr\u00e9senter les billets qui ont\r\n\u00e9t\u00e9 distribu\u00e9s au bur\u00f4.\r\n{% endif %}\r\n\r\nSi tu ne peux plus assister \u00e0 cette repr\u00e9sentation, tu peux\r\nrevendre ta place via BdA-revente, accessible directement sur\r\nGestioCOF (lien \"revendre une place du premier tirage\" sur la page\r\nd'accueil https://www.cof.ens.fr/gestion/).\r\n\r\nEn te souhaitant un excellent spectacle,\r\n\r\nLe Bureau des Arts", + "description": "Mail de rappel pour les spectacles BdA" + }, + "pk": 2 }, { "model": "custommail.custommail", @@ -150,8 +163,8 @@ "fields": { "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" + "description": "Notification envoy\u00e9e \u00e0 toutes les personnes int\u00e9ress\u00e9es par un spectacle pour leur 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-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" } }, { @@ -161,427 +174,427 @@ "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" } }, { "model": "custommail.custommail", - "pk": 5, "fields": { "shortname": "bda-revente-winner", "subject": "BdA-Revente : {{ show.title }}", - "description": "Mail envoy\u00e9 au gagnant d'un tirage BdA-Revente", - "body": "Bonjour {{ acheteur.first_name }},\r\n\r\nTu as \u00e9t\u00e9 tir\u00e9-e au sort pour racheter une place pour {{ show.title }} le {{ show.date }} ({{ show.location }}) \u00e0 {{ show.price|floatformat:2 }}\u20ac.\r\nTu peux contacter le/la vendeur-se \u00e0 l'adresse {{ vendeur.email }}.\r\n\r\nChaleureusement,\r\nLe BdA" - } + "body": "Bonjour {{ acheteur.first_name }},\r\n\r\nTu as \u00e9t\u00e9 tir\u00e9-e au sort pour racheter une place pour {{ show.title }} le {{ show.date }} ({{ show.location }}) \u00e0 {{ show.price|floatformat:2 }}\u20ac.\r\nTu peux contacter le/la vendeur-se \u00e0 l'adresse {{ vendeur.email }}.\r\n\r\nChaleureusement,\r\nLe BdA", + "description": "Mail envoy\u00e9 au gagnant d'un tirage BdA-Revente" + }, + "pk": 5 }, { "model": "custommail.custommail", - "pk": 6, "fields": { "shortname": "bda-revente-loser", "subject": "BdA-Revente : {{ show.title }}", - "description": "Notification envoy\u00e9e aux perdants d'un tirage de revente.", - "body": "Bonjour {{ acheteur.first_name }},\r\n\r\nTu t'\u00e9tais inscrit-e pour la revente de la place de {{ vendeur.get_full_name }}\r\npour {{ show.title }}.\r\nMalheureusement, une autre personne a \u00e9t\u00e9 tir\u00e9e au sort pour racheter la place.\r\nTu pourras certainement retenter ta chance pour une autre revente !\r\n\r\n\u00c0 tr\u00e8s bient\u00f4t,\r\nLe Bureau des Arts" - } + "body": "Bonjour {{ acheteur.first_name }},\r\n\r\nTu t'\u00e9tais inscrit-e pour la revente de la place de {{ vendeur.get_full_name }}\r\npour {{ show.title }}.\r\nMalheureusement, une autre personne a \u00e9t\u00e9 tir\u00e9e au sort pour racheter la place.\r\nTu pourras certainement retenter ta chance pour une autre revente !\r\n\r\n\u00c0 tr\u00e8s bient\u00f4t,\r\nLe Bureau des Arts", + "description": "Notification envoy\u00e9e aux perdants d'un tirage de revente." + }, + "pk": 6 }, { "model": "custommail.custommail", - "pk": 7, "fields": { "shortname": "bda-revente-seller", "subject": "BdA-Revente : {{ show.title }}", - "description": "Notification envoy\u00e9e au vendeur d'une place pour lui indiquer qu'elle vient d'\u00eatre attribu\u00e9e", - "body": "Bonjour {{ vendeur.first_name }},\r\n\r\nLa personne tir\u00e9e au sort pour racheter ta place pour {{ show.title }} est {{ acheteur.get_full_name }}.\r\nTu peux le/la contacter \u00e0 l'adresse {{ acheteur.email }}, ou en r\u00e9pondant \u00e0 ce mail.\r\n\r\nChaleureusement,\r\nLe BdA" - } + "body": "Bonjour {{ vendeur.first_name }},\r\n\r\nLa personne tir\u00e9e au sort pour racheter ta place pour {{ show.title }} est {{ acheteur.get_full_name }}.\r\nTu peux le/la contacter \u00e0 l'adresse {{ acheteur.email }}, ou en r\u00e9pondant \u00e0 ce mail.\r\n\r\nChaleureusement,\r\nLe BdA", + "description": "Notification envoy\u00e9e au vendeur d'une place pour lui indiquer qu'elle vient d'\u00eatre attribu\u00e9e" + }, + "pk": 7 }, { "model": "custommail.custommail", - "pk": 8, "fields": { "shortname": "bda-revente-new", "subject": "BdA-Revente : {{ show.title }}", - "description": "Notification signalant au vendeur d'une place que sa mise en vente a bien eu lieu et lui donnant quelques informations compl\u00e9mentaires.", - "body": "Bonjour {{ vendeur.first_name }},\r\n\r\nTu t\u2019es bien inscrit-e pour la revente de {{ show.title }}.\r\n\r\n{% with revente.date_tirage as time %}\r\nLe tirage au sort entre tout-e-s les racheteuse-eur-s potentiel-le-s aura lieu\r\nle {{ time|date:\"DATE_FORMAT\" }} \u00e0 {{ time|time:\"TIME_FORMAT\" }} (dans {{time|timeuntil }}).\r\nSi personne ne s\u2019est inscrit pour racheter la place, celle-ci apparaitra parmi\r\nles \u00ab Places disponibles imm\u00e9diatement \u00e0 la revente \u00bb sur GestioCOF.\r\n{% endwith %}\r\n\r\nBonne revente !\r\nLe Bureau des Arts" - } + "body": "Bonjour {{ vendeur.first_name }},\r\n\r\nTu t\u2019es bien inscrit-e pour la revente de {{ show.title }}.\r\n\r\n{% with revente.date_tirage as time %}\r\nLe tirage au sort entre tout-e-s les racheteuse-eur-s potentiel-le-s aura lieu\r\nle {{ time|date:\"DATE_FORMAT\" }} \u00e0 {{ time|time:\"TIME_FORMAT\" }} (dans {{time|timeuntil }}).\r\nSi personne ne s\u2019est inscrit pour racheter la place, celle-ci apparaitra parmi\r\nles \u00ab Places disponibles imm\u00e9diatement \u00e0 la revente \u00bb sur GestioCOF.\r\n{% endwith %}\r\n\r\nBonne revente !\r\nLe Bureau des Arts", + "description": "Notification signalant au vendeur d'une place que sa mise en vente a bien eu lieu et lui donnant quelques informations compl\u00e9mentaires." + }, + "pk": 8 }, { "model": "custommail.custommail", - "pk": 9, "fields": { "shortname": "bda-buy-shotgun", "subject": "BdA-Revente : {{ show.title }}", - "description": "Mail envoy\u00e9 au revendeur lors d'un achat au shotgun.", - "body": "Bonjour {{ vendeur.first_name }} !\r\n\r\nJe souhaiterais racheter ta place pour {{ show.title }} le {{ show.date }} ({{ show.location }}) \u00e0 {{ show.price|floatformat:2 }}\u20ac.\r\nContacte-moi si tu es toujours int\u00e9ress\u00e9\u00b7e !\r\n\r\n{{ acheteur.get_full_name }} ({{ acheteur.email }})" - } + "body": "Bonjour {{ vendeur.first_name }} !\r\n\r\nJe souhaiterais racheter ta place pour {{ show.title }} le {{ show.date }} ({{ show.location }}) \u00e0 {{ show.price|floatformat:2 }}\u20ac.\r\nContacte-moi si tu es toujours int\u00e9ress\u00e9\u00b7e !\r\n\r\n{{ acheteur.get_full_name }} ({{ acheteur.email }})", + "description": "Mail envoy\u00e9 au revendeur lors d'un achat au shotgun." + }, + "pk": 9 }, { "model": "custommail.custommail", - "pk": 10, "fields": { "shortname": "petit-cours-mail-eleve", "subject": "Petits cours ENS par le COF", - "description": "Mail envoy\u00e9 aux personnes dont ont a donn\u00e9 les contacts \u00e0 des demandeurs de petits cours", - "body": "Salut,\r\n\r\nLe COF a re\u00e7u une demande de petit cours qui te correspond. Tu es en haut de la liste d'attente donc on a transmis tes coordonn\u00e9es, ainsi que celles de 2 autres qui correspondaient aussi (c'est la vie, on donne les num\u00e9ros 3 par 3 pour que ce soit plus souple). Voici quelques infos sur l'annonce en question :\r\n\r\n\u00a4 Nom : {{ demande.name }}\r\n\r\n\u00a4 P\u00e9riode : {{ demande.quand }}\r\n\r\n\u00a4 Fr\u00e9quence : {{ demande.freq }}\r\n\r\n\u00a4 Lieu (si pr\u00e9f\u00e9r\u00e9) : {{ demande.lieu }}\r\n\r\n\u00a4 Niveau : {{ demande.get_niveau_display }}\r\n\r\n\u00a4 Remarques diverses (d\u00e9sol\u00e9 pour les balises HTML) : {{ demande.remarques }}\r\n\r\n{% if matieres|length > 1 %}\u00a4 Mati\u00e8res :\r\n{% for matiere in matieres %} \u00a4 {{ matiere }}\r\n{% endfor %}{% else %}\u00a4 Mati\u00e8re : {% for matiere in matieres %}{{ matiere }}\r\n{% endfor %}{% endif %}\r\nVoil\u00e0, cette personne te contactera peut-\u00eatre sous peu, tu pourras voir les d\u00e9tails directement avec elle (prix, modalit\u00e9s, ...). Pour indication, 30 Euro/h semble \u00eatre la moyenne.\r\n\r\nSi tu te rends compte qu'en fait tu ne peux pas/plus donner de cours en ce moment, \u00e7a serait cool que tu d\u00e9coches la case \"Recevoir des propositions de petits cours\" sur GestioCOF. Ensuite d\u00e8s que tu voudras r\u00e9appara\u00eetre tu pourras recocher la case et tu seras \u00e0 nouveau sur la liste.\r\n\r\n\u00c0 bient\u00f4t,\r\n\r\n--\r\nLe COF, pour les petits cours" - } + "body": "Salut,\r\n\r\nLe COF a re\u00e7u une demande de petit cours qui te correspond. Tu es en haut de la liste d'attente donc on a transmis tes coordonn\u00e9es, ainsi que celles de 2 autres qui correspondaient aussi (c'est la vie, on donne les num\u00e9ros 3 par 3 pour que ce soit plus souple). Voici quelques infos sur l'annonce en question :\r\n\r\n\u00a4 Nom : {{ demande.name }}\r\n\r\n\u00a4 P\u00e9riode : {{ demande.quand }}\r\n\r\n\u00a4 Fr\u00e9quence : {{ demande.freq }}\r\n\r\n\u00a4 Lieu (si pr\u00e9f\u00e9r\u00e9) : {{ demande.lieu }}\r\n\r\n\u00a4 Niveau : {{ demande.get_niveau_display }}\r\n\r\n\u00a4 Remarques diverses (d\u00e9sol\u00e9 pour les balises HTML) : {{ demande.remarques }}\r\n\r\n{% if matieres|length > 1 %}\u00a4 Mati\u00e8res :\r\n{% for matiere in matieres %} \u00a4 {{ matiere }}\r\n{% endfor %}{% else %}\u00a4 Mati\u00e8re : {% for matiere in matieres %}{{ matiere }}\r\n{% endfor %}{% endif %}\r\nVoil\u00e0, cette personne te contactera peut-\u00eatre sous peu, tu pourras voir les d\u00e9tails directement avec elle (prix, modalit\u00e9s, ...). Pour indication, 30 Euro/h semble \u00eatre la moyenne.\r\n\r\nSi tu te rends compte qu'en fait tu ne peux pas/plus donner de cours en ce moment, \u00e7a serait cool que tu d\u00e9coches la case \"Recevoir des propositions de petits cours\" sur GestioCOF. Ensuite d\u00e8s que tu voudras r\u00e9appara\u00eetre tu pourras recocher la case et tu seras \u00e0 nouveau sur la liste.\r\n\r\n\u00c0 bient\u00f4t,\r\n\r\n--\r\nLe COF, pour les petits cours", + "description": "Mail envoy\u00e9 aux personnes dont ont a donn\u00e9 les contacts \u00e0 des demandeurs de petits cours" + }, + "pk": 10 }, { "model": "custommail.custommail", - "pk": 11, "fields": { "shortname": "petits-cours-mail-demandeur", "subject": "Cours particuliers ENS", - "description": "Mail envoy\u00e9 aux personnent qui demandent des petits cours lorsque leur demande est trait\u00e9e.\r\n\r\n(Ne pas toucher \u00e0 {{ extra|safe }})", - "body": "Bonjour,\r\n\r\nJe vous contacte au sujet de votre annonce pass\u00e9e sur le site du COF pour rentrer en contact avec un \u00e9l\u00e8ve normalien pour des cours particuliers. Voici les coordonn\u00e9es d'\u00e9l\u00e8ves qui sont motiv\u00e9s par de tels cours et correspondent aux crit\u00e8res que vous nous aviez transmis :\r\n\r\n{% for matiere, proposed in proposals %}\u00a4 {{ matiere }} :{% for user in proposed %}\r\n \u00a4 {{ user.get_full_name }}{% if user.profile.phone %}, {{ user.profile.phone }}{% endif %}{% if user.email %}, {{ user.email }}{% endif %}{% endfor %}\r\n\r\n{% endfor %}{% if unsatisfied %}Nous n'avons cependant pas pu trouver d'\u00e9l\u00e8ve disponible pour des cours de {% for matiere in unsatisfied %}{% if forloop.counter0 > 0 %}, {% endif %}{{ matiere }}{% endfor %}.\r\n\r\n{% endif %}Si pour une raison ou une autre ces num\u00e9ros ne suffisaient pas, n'h\u00e9sitez pas \u00e0 r\u00e9pondre \u00e0 cet e-mail et je vous en ferai parvenir d'autres sans probl\u00e8me.\r\n{% if extra|length > 0 %}\r\n{{ extra|safe }}\r\n{% endif %}\r\nCordialement,\r\n\r\n--\r\nLe COF, BdE de l'ENS" - } + "body": "Bonjour,\r\n\r\nJe vous contacte au sujet de votre annonce pass\u00e9e sur le site du COF pour rentrer en contact avec un \u00e9l\u00e8ve normalien pour des cours particuliers. Voici les coordonn\u00e9es d'\u00e9l\u00e8ves qui sont motiv\u00e9s par de tels cours et correspondent aux crit\u00e8res que vous nous aviez transmis :\r\n\r\n{% for matiere, proposed in proposals %}\u00a4 {{ matiere }} :{% for user in proposed %}\r\n \u00a4 {{ user.get_full_name }}{% if user.profile.phone %}, {{ user.profile.phone }}{% endif %}{% if user.email %}, {{ user.email }}{% endif %}{% endfor %}\r\n\r\n{% endfor %}{% if unsatisfied %}Nous n'avons cependant pas pu trouver d'\u00e9l\u00e8ve disponible pour des cours de {% for matiere in unsatisfied %}{% if forloop.counter0 > 0 %}, {% endif %}{{ matiere }}{% endfor %}.\r\n\r\n{% endif %}Si pour une raison ou une autre ces num\u00e9ros ne suffisaient pas, n'h\u00e9sitez pas \u00e0 r\u00e9pondre \u00e0 cet e-mail et je vous en ferai parvenir d'autres sans probl\u00e8me.\r\n{% if extra|length > 0 %}\r\n{{ extra|safe }}\r\n{% endif %}\r\nCordialement,\r\n\r\n--\r\nLe COF, BdE de l'ENS", + "description": "Mail envoy\u00e9 aux personnes qui demandent des petits cours lorsque leur demande est trait\u00e9e.\r\n\r\n(Ne pas toucher \u00e0 {{ extra|safe }})" + }, + "pk": 11 }, { "model": "custommail.custommail", - "pk": 12, "fields": { "shortname": "bda-attributions", "subject": "R\u00e9sultats du tirage au sort", - "description": "Mail annon\u00e7ant les r\u00e9sultats du tirage au sort du BdA aux gagnants d'une ou plusieurs places", - "body": "Cher-e {{ member.first_name }},\r\n\r\nTu t'es inscrit-e pour le tirage au sort du BdA. Tu as \u00e9t\u00e9 s\u00e9lectionn\u00e9-e\r\npour les spectacles suivants :\r\n{% for place in places %}\r\n- 1 place pour {{ place }}{% endfor %}\r\n\r\n*Paiement*\r\nL'int\u00e9gralit\u00e9 de ces places de spectacles est \u00e0 r\u00e9gler d\u00e8s maintenant et AVANT\r\nvendredi prochain, au bureau du COF pendant les heures de permanences (du lundi au vendredi\r\nentre 12h et 14h, et entre 18h et 20h). Des facilit\u00e9s de paiement sont bien\r\n\u00e9videmment possibles : nous pouvons ne pas encaisser le ch\u00e8que imm\u00e9diatement,\r\nou bien d\u00e9couper votre paiement en deux fois. Pour ceux qui ne pourraient pas\r\nvenir payer au bureau, merci de nous contacter par mail.\r\n\r\n*Mode de retrait des places*\r\nAu moment du paiement, certaines places vous seront remises directement,\r\nd'autres seront \u00e0 r\u00e9cup\u00e9rer au cours de l'ann\u00e9e, d'autres encore seront\r\nnominatives et \u00e0 retirer le soir m\u00eame dans les the\u00e2tres correspondants.\r\nPour chaque spectacle, vous recevrez un mail quelques jours avant la\r\nrepr\u00e9sentation vous indiquant le mode de retrait.\r\n\r\nNous vous rappelons que l'obtention de places du BdA vous engage \u00e0\r\nrespecter les r\u00e8gles de fonctionnement :\r\nhttp://www.cof.ens.fr/bda/?page_id=1370\r\nUn syst\u00e8me de revente des places via les mails BdA-revente disponible\r\ndirectement sur votre compte GestioCOF.\r\n\r\nEn vous souhaitant de tr\u00e8s beaux spectacles tout au long de l'ann\u00e9e,\r\n--\r\nLe Bureau des Arts" - } + "body": "Cher-e {{ member.first_name }},\r\n\r\nTu t'es inscrit-e pour le tirage au sort du BdA. Tu as \u00e9t\u00e9 s\u00e9lectionn\u00e9-e\r\npour les spectacles suivants :\r\n{% for place in places %}\r\n- 1 place pour {{ place }}{% endfor %}\r\n\r\n*Paiement*\r\nL'int\u00e9gralit\u00e9 de ces places de spectacles est \u00e0 r\u00e9gler d\u00e8s maintenant et AVANT\r\nvendredi prochain, au bureau du COF pendant les heures de permanences (du lundi au vendredi\r\nentre 12h et 14h, et entre 18h et 20h). Des facilit\u00e9s de paiement sont bien\r\n\u00e9videmment possibles : nous pouvons ne pas encaisser le ch\u00e8que imm\u00e9diatement,\r\nou bien d\u00e9couper votre paiement en deux fois. Pour ceux qui ne pourraient pas\r\nvenir payer au bureau, merci de nous contacter par mail.\r\n\r\n*Mode de retrait des places*\r\nAu moment du paiement, certaines places vous seront remises directement,\r\nd'autres seront \u00e0 r\u00e9cup\u00e9rer au cours de l'ann\u00e9e, d'autres encore seront\r\nnominatives et \u00e0 retirer le soir m\u00eame dans les the\u00e2tres correspondants.\r\nPour chaque spectacle, vous recevrez un mail quelques jours avant la\r\nrepr\u00e9sentation vous indiquant le mode de retrait.\r\n\r\nNous vous rappelons que l'obtention de places du BdA vous engage \u00e0\r\nrespecter les r\u00e8gles de fonctionnement :\r\nhttp://www.cof.ens.fr/bda/?page_id=1370\r\nUn syst\u00e8me de revente des places via les mails BdA-revente disponible\r\ndirectement sur votre compte GestioCOF.\r\n\r\nEn vous souhaitant de tr\u00e8s beaux spectacles tout au long de l'ann\u00e9e,\r\n--\r\nLe Bureau des Arts", + "description": "Mail annon\u00e7ant les r\u00e9sultats du tirage au sort du BdA aux gagnants d'une ou plusieurs places" + }, + "pk": 12 }, { "model": "custommail.custommail", - "pk": 13, "fields": { "shortname": "bda-attributions-decus", "subject": "R\u00e9sultats du tirage au sort", - "description": "Mail annon\u00e7ant les r\u00e9sultats du tirage au sort du BdA aux personnes n'ayant pas obtenu de place", - "body": "Cher-e {{ member.first_name }},\r\n\r\nTu t'es inscrit-e pour le tirage au sort du BdA. Malheureusement, tu n'as\r\nobtenu aucune place.\r\n\r\nNous proposons cependant de nombreuses offres hors-tirage tout au long de\r\nl'ann\u00e9e, et nous t'invitons \u00e0 nous contacter si l'une d'entre elles\r\nt'int\u00e9resse !\r\n--\r\nLe Bureau des Arts" - } + "body": "Cher-e {{ member.first_name }},\r\n\r\nTu t'es inscrit-e pour le tirage au sort du BdA. Malheureusement, tu n'as\r\nobtenu aucune place.\r\n\r\nNous proposons cependant de nombreuses offres hors-tirage tout au long de\r\nl'ann\u00e9e, et nous t'invitons \u00e0 nous contacter si l'une d'entre elles\r\nt'int\u00e9resse !\r\n--\r\nLe Bureau des Arts", + "description": "Mail annon\u00e7ant les r\u00e9sultats du tirage au sort du BdA aux personnes n'ayant pas obtenu de place" + }, + "pk": 13 }, { - "model": "custommail.custommailvariable", - "pk": 1, + "model": "custommail.variable", "fields": { - "name": "member", - "description": "Utilisateur de GestioCOF", "custommail": 1, - "type": 1 - } -}, -{ - "model": "custommail.custommailvariable", - "pk": 2, - "fields": { + "type": 1, "name": "member", - "description": "Utilisateur ayant eu une place pour ce spectacle", - "custommail": 2, - "type": 1 - } + "description": "Utilisateur de GestioCOF" + }, + "pk": 1 }, { - "model": "custommail.custommailvariable", - "pk": 3, + "model": "custommail.variable", "fields": { + "custommail": 2, + "type": 1, + "name": "member", + "description": "Utilisateur ayant eu une place pour ce spectacle" + }, + "pk": 2 +}, +{ + "model": "custommail.variable", + "fields": { + "custommail": 2, + "type": 3, "name": "show", - "description": "Spectacle", - "custommail": 2, - "type": 3 - } + "description": "Spectacle" + }, + "pk": 3 }, { - "model": "custommail.custommailvariable", - "pk": 4, + "model": "custommail.variable", "fields": { + "custommail": 2, + "type": 2, "name": "nb_attr", - "description": "Nombre de places obtenues", - "custommail": 2, - "type": 2 - } + "description": "Nombre de places obtenues" + }, + "pk": 4 }, { - "model": "custommail.custommailvariable", - "pk": 5, + "model": "custommail.variable", "fields": { + "custommail": 3, + "type": 4, "name": "revente", - "description": "Revente mentionn\u00e9e dans le mail", - "custommail": 3, - "type": 4 - } + "description": "Revente mentionn\u00e9e dans le mail" + }, + "pk": 5 }, { - "model": "custommail.custommailvariable", - "pk": 6, + "model": "custommail.variable", "fields": { + "custommail": 3, + "type": 1, "name": "member", - "description": "Personne int\u00e9ress\u00e9e par la place", - "custommail": 3, - "type": 1 - } + "description": "Personne int\u00e9ress\u00e9e par la place" + }, + "pk": 6 }, { - "model": "custommail.custommailvariable", - "pk": 7, + "model": "custommail.variable", "fields": { + "custommail": 3, + "type": 3, "name": "show", - "description": "Spectacle", - "custommail": 3, - "type": 3 - } + "description": "Spectacle" + }, + "pk": 7 }, { - "model": "custommail.custommailvariable", - "pk": 8, + "model": "custommail.variable", "fields": { - "name": "site", - "description": "Site web (gestioCOF)", "custommail": 3, - "type": 5 - } + "type": 5, + "name": "site", + "description": "Site web (gestioCOF)" + }, + "pk": 8 }, { - "model": "custommail.custommailvariable", - "pk": 9, + "model": "custommail.variable", "fields": { - "name": "site", - "description": "Site web (gestioCOF)", "custommail": 4, - "type": 5 - } + "type": 5, + "name": "site", + "description": "Site web (gestioCOF)" + }, + "pk": 9 }, { - "model": "custommail.custommailvariable", - "pk": 10, + "model": "custommail.variable", "fields": { + "custommail": 4, + "type": 3, "name": "show", - "description": "Spectacle", - "custommail": 4, - "type": 3 - } + "description": "Spectacle" + }, + "pk": 10 }, { - "model": "custommail.custommailvariable", - "pk": 11, + "model": "custommail.variable", "fields": { + "custommail": 4, + "type": 1, "name": "member", - "description": "Personne int\u00e9ress\u00e9e par la place", - "custommail": 4, - "type": 1 - } + "description": "Personne int\u00e9ress\u00e9e par la place" + }, + "pk": 11 }, { - "model": "custommail.custommailvariable", - "pk": 12, + "model": "custommail.variable", "fields": { - "name": "acheteur", - "description": "Gagnant-e du tirage", "custommail": 5, - "type": 1 - } -}, -{ - "model": "custommail.custommailvariable", - "pk": 13, - "fields": { - "name": "vendeur", - "description": "Personne qui vend une place", - "custommail": 5, - "type": 1 - } -}, -{ - "model": "custommail.custommailvariable", - "pk": 14, - "fields": { - "name": "show", - "description": "Spectacle", - "custommail": 5, - "type": 3 - } -}, -{ - "model": "custommail.custommailvariable", - "pk": 15, - "fields": { - "name": "show", - "description": "Spectacle", - "custommail": 6, - "type": 3 - } -}, -{ - "model": "custommail.custommailvariable", - "pk": 16, - "fields": { - "name": "vendeur", - "description": "Personne qui vend une place", - "custommail": 6, - "type": 1 - } -}, -{ - "model": "custommail.custommailvariable", - "pk": 17, - "fields": { + "type": 1, "name": "acheteur", - "description": "Personne inscrite au tirage qui n'a pas eu la place", - "custommail": 6, - "type": 1 - } + "description": "Gagnant-e du tirage" + }, + "pk": 12 }, { - "model": "custommail.custommailvariable", - "pk": 18, - "fields": { - "name": "acheteur", - "description": "Gagnant-e du tirage", - "custommail": 7, - "type": 1 - } -}, -{ - "model": "custommail.custommailvariable", - "pk": 19, + "model": "custommail.variable", "fields": { + "custommail": 5, + "type": 1, "name": "vendeur", - "description": "Personne qui vend une place", - "custommail": 7, - "type": 1 - } + "description": "Personne qui vend une place" + }, + "pk": 13 }, { - "model": "custommail.custommailvariable", - "pk": 20, + "model": "custommail.variable", "fields": { + "custommail": 5, + "type": 3, "name": "show", - "description": "Spectacle", - "custommail": 7, - "type": 3 - } + "description": "Spectacle" + }, + "pk": 14 }, { - "model": "custommail.custommailvariable", - "pk": 21, + "model": "custommail.variable", "fields": { + "custommail": 6, + "type": 3, "name": "show", - "description": "Spectacle", + "description": "Spectacle" + }, + "pk": 15 +}, +{ + "model": "custommail.variable", + "fields": { + "custommail": 6, + "type": 1, + "name": "vendeur", + "description": "Personne qui vend une place" + }, + "pk": 16 +}, +{ + "model": "custommail.variable", + "fields": { + "custommail": 6, + "type": 1, + "name": "acheteur", + "description": "Personne inscrite au tirage qui n'a pas eu la place" + }, + "pk": 17 +}, +{ + "model": "custommail.variable", + "fields": { + "custommail": 7, + "type": 1, + "name": "acheteur", + "description": "Gagnant-e du tirage" + }, + "pk": 18 +}, +{ + "model": "custommail.variable", + "fields": { + "custommail": 7, + "type": 1, + "name": "vendeur", + "description": "Personne qui vend une place" + }, + "pk": 19 +}, +{ + "model": "custommail.variable", + "fields": { + "custommail": 7, + "type": 3, + "name": "show", + "description": "Spectacle" + }, + "pk": 20 +}, +{ + "model": "custommail.variable", + "fields": { "custommail": 8, - "type": 3 - } + "type": 3, + "name": "show", + "description": "Spectacle" + }, + "pk": 21 }, { - "model": "custommail.custommailvariable", - "pk": 22, + "model": "custommail.variable", "fields": { - "name": "vendeur", - "description": "Personne qui vend la place", "custommail": 8, - "type": 1 - } + "type": 1, + "name": "vendeur", + "description": "Personne qui vend la place" + }, + "pk": 22 }, { - "model": "custommail.custommailvariable", - "pk": 23, + "model": "custommail.variable", "fields": { + "custommail": 8, + "type": 4, "name": "revente", - "description": "Revente mentionn\u00e9e dans le mail", - "custommail": 8, - "type": 4 - } + "description": "Revente mentionn\u00e9e dans le mail" + }, + "pk": 23 }, { - "model": "custommail.custommailvariable", - "pk": 24, + "model": "custommail.variable", "fields": { + "custommail": 9, + "type": 1, "name": "vendeur", - "description": "Personne qui vend la place", - "custommail": 9, - "type": 1 - } + "description": "Personne qui vend la place" + }, + "pk": 24 }, { - "model": "custommail.custommailvariable", - "pk": 25, + "model": "custommail.variable", "fields": { + "custommail": 9, + "type": 3, "name": "show", - "description": "Spectacle", - "custommail": 9, - "type": 3 - } + "description": "Spectacle" + }, + "pk": 25 }, { - "model": "custommail.custommailvariable", - "pk": 26, + "model": "custommail.variable", "fields": { + "custommail": 9, + "type": 1, "name": "acheteur", - "description": "Personne qui prend la place au shotgun", - "custommail": 9, - "type": 1 - } + "description": "Personne qui prend la place au shotgun" + }, + "pk": 26 }, { - "model": "custommail.custommailvariable", - "pk": 27, + "model": "custommail.variable", "fields": { + "custommail": 10, + "type": 6, "name": "demande", - "description": "Demande de petit cours", - "custommail": 10, - "type": 6 - } + "description": "Demande de petit cours" + }, + "pk": 27 }, { - "model": "custommail.custommailvariable", - "pk": 28, + "model": "custommail.variable", "fields": { + "custommail": 10, + "type": 7, "name": "matieres", - "description": "Liste des mati\u00e8res concern\u00e9es par la demande", - "custommail": 10, - "type": 7 - } + "description": "Liste des mati\u00e8res concern\u00e9es par la demande" + }, + "pk": 28 }, { - "model": "custommail.custommailvariable", - "pk": 29, + "model": "custommail.variable", "fields": { + "custommail": 11, + "type": 10, "name": "proposals", - "description": "Liste associant une liste d'enseignants \u00e0 chaque mati\u00e8re", - "custommail": 11, - "type": 10 - } + "description": "Liste associant une liste d'enseignants \u00e0 chaque mati\u00e8re" + }, + "pk": 29 }, { - "model": "custommail.custommailvariable", - "pk": 30, + "model": "custommail.variable", "fields": { + "custommail": 11, + "type": 7, "name": "unsatisfied", - "description": "Liste des mati\u00e8res pour lesquelles on n'a pas d'enseigant \u00e0 proposer", - "custommail": 11, - "type": 7 - } + "description": "Liste des mati\u00e8res pour lesquelles on n'a pas d'enseigant \u00e0 proposer" + }, + "pk": 30 }, { - "model": "custommail.custommailvariable", - "pk": 31, + "model": "custommail.variable", "fields": { + "custommail": 12, + "type": 11, "name": "places", - "description": "Places de spectacle du participant", - "custommail": 12, - "type": 11 - } + "description": "Places de spectacle du participant" + }, + "pk": 31 }, { - "model": "custommail.custommailvariable", - "pk": 32, + "model": "custommail.variable", "fields": { - "name": "member", - "description": "Participant du tirage au sort", "custommail": 12, - "type": 1 - } + "type": 1, + "name": "member", + "description": "Participant du tirage au sort" + }, + "pk": 32 }, { - "model": "custommail.custommailvariable", - "pk": 33, + "model": "custommail.variable", "fields": { - "name": "member", - "description": "Participant du tirage au sort", "custommail": 13, - "type": 1 - } + "type": 1, + "name": "member", + "description": "Participant du tirage au sort" + }, + "pk": 33 } ] diff --git a/gestioncof/migrations/0001_initial.py b/gestioncof/migrations/0001_initial.py index b942645d..c6bb6151 100644 --- a/gestioncof/migrations/0001_initial.py +++ b/gestioncof/migrations/0001_initial.py @@ -48,7 +48,7 @@ class Migration(migrations.Migration): ('is_buro', models.BooleanField(default=False, verbose_name=b'Membre du Bur\xc3\xb4')), ('petits_cours_accept', models.BooleanField(default=False, verbose_name=b'Recevoir des petits cours')), ('petits_cours_remarques', models.TextField(default=b'', verbose_name='Remarques et pr\xe9cisions pour les petits cours', blank=True)), - ('user', models.OneToOneField(related_name='profile', to=settings.AUTH_USER_MODEL)), + ('user', models.OneToOneField(related_name='profile', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ 'verbose_name': 'Profil COF', @@ -91,7 +91,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=200, verbose_name=b'Champ')), ('fieldtype', models.CharField(default=b'text', max_length=10, verbose_name=b'Type', choices=[(b'text', 'Texte long'), (b'char', 'Texte court')])), ('default', models.TextField(verbose_name=b'Valeur par d\xc3\xa9faut', blank=True)), - ('event', models.ForeignKey(related_name='commentfields', to='gestioncof.Event')), + ('event', models.ForeignKey(related_name='commentfields', to='gestioncof.Event', on_delete=models.CASCADE)), ], options={ 'verbose_name': 'Champ', @@ -102,7 +102,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('content', models.TextField(null=True, verbose_name=b'Contenu', blank=True)), - ('commentfield', models.ForeignKey(related_name='values', to='gestioncof.EventCommentField')), + ('commentfield', models.ForeignKey(related_name='values', to='gestioncof.EventCommentField', on_delete=models.CASCADE)), ], ), migrations.CreateModel( @@ -111,7 +111,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=200, verbose_name=b'Option')), ('multi_choices', models.BooleanField(default=False, verbose_name=b'Choix multiples')), - ('event', models.ForeignKey(related_name='options', to='gestioncof.Event')), + ('event', models.ForeignKey(related_name='options', to='gestioncof.Event', on_delete=models.CASCADE)), ], options={ 'verbose_name': 'Option', @@ -122,7 +122,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('value', models.CharField(max_length=200, verbose_name=b'Valeur')), - ('event_option', models.ForeignKey(related_name='choices', to='gestioncof.EventOption')), + ('event_option', models.ForeignKey(related_name='choices', to='gestioncof.EventOption', on_delete=models.CASCADE)), ], options={ 'verbose_name': 'Choix', @@ -133,10 +133,10 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('paid', models.BooleanField(default=False, verbose_name=b'A pay\xc3\xa9')), - ('event', models.ForeignKey(to='gestioncof.Event')), + ('event', models.ForeignKey(to='gestioncof.Event', on_delete=models.CASCADE)), ('filledcomments', models.ManyToManyField(to='gestioncof.EventCommentField', through='gestioncof.EventCommentValue')), ('options', models.ManyToManyField(to='gestioncof.EventOptionChoice')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ 'verbose_name': 'Inscription', @@ -240,7 +240,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('question', models.CharField(max_length=200, verbose_name=b'Question')), ('multi_answers', models.BooleanField(default=False, verbose_name=b'Choix multiples')), - ('survey', models.ForeignKey(related_name='questions', to='gestioncof.Survey')), + ('survey', models.ForeignKey(related_name='questions', to='gestioncof.Survey', on_delete=models.CASCADE)), ], options={ 'verbose_name': 'Question', @@ -251,7 +251,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('answer', models.CharField(max_length=200, verbose_name=b'R\xc3\xa9ponse')), - ('survey_question', models.ForeignKey(related_name='answers', to='gestioncof.SurveyQuestion')), + ('survey_question', models.ForeignKey(related_name='answers', to='gestioncof.SurveyQuestion', on_delete=models.CASCADE)), ], options={ 'verbose_name': 'R\xe9ponse', @@ -265,12 +265,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='surveyanswer', name='survey', - field=models.ForeignKey(to='gestioncof.Survey'), + field=models.ForeignKey(to='gestioncof.Survey', on_delete=models.CASCADE), ), migrations.AddField( model_name='surveyanswer', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), ), migrations.AddField( model_name='petitcoursdemande', @@ -280,47 +280,47 @@ class Migration(migrations.Migration): migrations.AddField( model_name='petitcoursdemande', name='traitee_par', - field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True), + field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE), ), migrations.AddField( model_name='petitcoursattributioncounter', name='matiere', - field=models.ForeignKey(verbose_name='Matiere', to='gestioncof.PetitCoursSubject'), + field=models.ForeignKey(verbose_name='Matiere', to='gestioncof.PetitCoursSubject', on_delete=models.CASCADE), ), migrations.AddField( model_name='petitcoursattributioncounter', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), ), migrations.AddField( model_name='petitcoursattribution', name='demande', - field=models.ForeignKey(verbose_name='Demande', to='gestioncof.PetitCoursDemande'), + field=models.ForeignKey(verbose_name='Demande', to='gestioncof.PetitCoursDemande', on_delete=models.CASCADE), ), migrations.AddField( model_name='petitcoursattribution', name='matiere', - field=models.ForeignKey(verbose_name='Mati\xe8re', to='gestioncof.PetitCoursSubject'), + field=models.ForeignKey(verbose_name='Mati\xe8re', to='gestioncof.PetitCoursSubject', on_delete=models.CASCADE), ), migrations.AddField( model_name='petitcoursattribution', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), ), migrations.AddField( model_name='petitcoursability', name='matiere', - field=models.ForeignKey(verbose_name='Mati\xe8re', to='gestioncof.PetitCoursSubject'), + field=models.ForeignKey(verbose_name='Mati\xe8re', to='gestioncof.PetitCoursSubject', on_delete=models.CASCADE), ), migrations.AddField( model_name='petitcoursability', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), ), migrations.AddField( model_name='eventcommentvalue', name='registration', - field=models.ForeignKey(related_name='comments', to='gestioncof.EventRegistration'), + field=models.ForeignKey(related_name='comments', to='gestioncof.EventRegistration', on_delete=models.CASCADE), ), migrations.AlterUniqueTogether( name='surveyanswer', diff --git a/gestioncof/migrations/0006_add_calendar.py b/gestioncof/migrations/0006_add_calendar.py index 004d3602..27852f61 100644 --- a/gestioncof/migrations/0006_add_calendar.py +++ b/gestioncof/migrations/0006_add_calendar.py @@ -23,7 +23,8 @@ class Migration(migrations.Migration): ('subscribe_to_events', models.BooleanField(default=True)), ('subscribe_to_my_shows', models.BooleanField(default=True)), ('other_shows', models.ManyToManyField(to='bda.Spectacle')), - ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), + ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE)), ], ), migrations.AlterModelOptions( diff --git a/gestioncof/migrations/0011_longer_clippers.py b/gestioncof/migrations/0011_longer_clippers.py new file mode 100644 index 00000000..631d0ea8 --- /dev/null +++ b/gestioncof/migrations/0011_longer_clippers.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gestioncof', '0010_delete_custommail'), + ] + + operations = [ + migrations.AlterField( + model_name='cofprofile', + name='login_clipper', + field=models.CharField(verbose_name='Login clipper', blank=True, max_length=32), + ), + ] diff --git a/gestioncof/migrations/0011_remove_cofprofile_num.py b/gestioncof/migrations/0011_remove_cofprofile_num.py new file mode 100644 index 00000000..f39ce367 --- /dev/null +++ b/gestioncof/migrations/0011_remove_cofprofile_num.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('gestioncof', '0010_delete_custommail'), + ] + + operations = [ + migrations.RemoveField( + model_name='cofprofile', + name='num', + ), + ] diff --git a/gestioncof/migrations/0012_merge.py b/gestioncof/migrations/0012_merge.py new file mode 100644 index 00000000..39879346 --- /dev/null +++ b/gestioncof/migrations/0012_merge.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gestioncof', '0011_remove_cofprofile_num'), + ('gestioncof', '0011_longer_clippers'), + ] + + operations = [ + ] diff --git a/gestioncof/migrations/0013_pei.py b/gestioncof/migrations/0013_pei.py new file mode 100644 index 00000000..2fbddf1f --- /dev/null +++ b/gestioncof/migrations/0013_pei.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gestioncof', '0012_merge'), + ] + + operations = [ + migrations.AlterField( + model_name='cofprofile', + name='occupation', + field=models.CharField( + verbose_name='Occupation', + max_length=9, + default='1A', + choices=[ + ('exterieur', 'Extérieur'), + ('1A', '1A'), + ('2A', '2A'), + ('3A', '3A'), + ('4A', '4A'), + ('archicube', 'Archicube'), + ('doctorant', 'Doctorant'), + ('CST', 'CST'), + ('PEI', 'PEI') + ]), + ), + migrations.AlterField( + model_name='cofprofile', + name='type_cotiz', + field=models.CharField( + verbose_name='Type de cotisation', + max_length=9, + default='normalien', + choices=[ + ('etudiant', 'Normalien étudiant'), + ('normalien', 'Normalien élève'), + ('exterieur', 'Extérieur'), + ('gratis', 'Gratuit') + ]), + ), + ] diff --git a/gestioncof/models.py b/gestioncof/models.py index 2215b296..0d816155 100644 --- a/gestioncof/models.py +++ b/gestioncof/models.py @@ -1,46 +1,62 @@ -# -*- coding: utf-8 -*- - from django.db import models from django.dispatch import receiver from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ -from django.utils.encoding import python_2_unicode_compatible -import django.utils.six as six from django.db.models.signals import post_save, post_delete from gestioncof.petits_cours_models import choices_length from bda.models import Spectacle -OCCUPATION_CHOICES = ( - ('exterieur', _("Extérieur")), - ('1A', _("1A")), - ('2A', _("2A")), - ('3A', _("3A")), - ('4A', _("4A")), - ('archicube', _("Archicube")), - ('doctorant', _("Doctorant")), - ('CST', _("CST")), -) - -TYPE_COTIZ_CHOICES = ( - ('etudiant', _("Normalien étudiant")), - ('normalien', _("Normalien élève")), - ('exterieur', _("Extérieur")), -) - TYPE_COMMENT_FIELD = ( ('text', _("Texte long")), ('char', _("Texte court")), ) -@python_2_unicode_compatible class CofProfile(models.Model): - user = models.OneToOneField(User, related_name="profile") - login_clipper = models.CharField("Login clipper", max_length=8, blank=True) + STATUS_EXTE = "exterieur" + STATUS_1A = "1A" + STATUS_2A = "2A" + STATUS_3A = "3A" + STATUS_4A = "4A" + STATUS_ARCHI = "archicube" + STATUS_DOCTORANT = "doctorant" + STATUS_CST = "CST" + STATUS_PEI = "PEI" + + OCCUPATION_CHOICES = ( + (STATUS_EXTE, _("Extérieur")), + (STATUS_1A, _("1A")), + (STATUS_2A, _("2A")), + (STATUS_3A, _("3A")), + (STATUS_4A, _("4A")), + (STATUS_ARCHI, _("Archicube")), + (STATUS_DOCTORANT, _("Doctorant")), + (STATUS_CST, _("CST")), + (STATUS_PEI, _("PEI")), + ) + + COTIZ_ETUDIANT = "etudiant" + COTIZ_NORMALIEN = "normalien" + COTIZ_EXTE = "exterieur" + COTIZ_GRATIS = "gratis" + + TYPE_COTIZ_CHOICES = ( + (COTIZ_ETUDIANT, _("Normalien étudiant")), + (COTIZ_NORMALIEN, _("Normalien élève")), + (COTIZ_EXTE, _("Extérieur")), + (COTIZ_GRATIS, _("Gratuit")), + ) + + user = models.OneToOneField( + User, on_delete=models.CASCADE, + related_name="profile", + ) + login_clipper = models.CharField( + "Login clipper", max_length=32, blank=True + ) is_cof = models.BooleanField("Membre du COF", default=False) - num = models.IntegerField("Numéro d'adhérent", blank=True, default=0) phone = models.CharField("Téléphone", max_length=20, blank=True) occupation = models.CharField(_("Occupation"), default="1A", @@ -72,7 +88,7 @@ class CofProfile(models.Model): verbose_name_plural = "Profils COF" def __str__(self): - return six.text_type(self.user.username) + return self.user.username @receiver(post_save, sender=User) @@ -86,7 +102,6 @@ def post_delete_user(sender, instance, *args, **kwargs): instance.user.delete() -@python_2_unicode_compatible class Club(models.Model): name = models.CharField("Nom", max_length=200, unique=True) description = models.TextField("Description", blank=True) @@ -98,7 +113,6 @@ class Club(models.Model): return self.name -@python_2_unicode_compatible class Event(models.Model): title = models.CharField("Titre", max_length=200) location = models.CharField("Lieu", max_length=200) @@ -115,12 +129,14 @@ class Event(models.Model): verbose_name = "Événement" def __str__(self): - return six.text_type(self.title) + return self.title -@python_2_unicode_compatible class EventCommentField(models.Model): - event = models.ForeignKey(Event, related_name="commentfields") + event = models.ForeignKey( + Event, on_delete=models.CASCADE, + related_name="commentfields", + ) name = models.CharField("Champ", max_length=200) fieldtype = models.CharField("Type", max_length=10, choices=TYPE_COMMENT_FIELD, default="text") @@ -130,23 +146,29 @@ class EventCommentField(models.Model): verbose_name = "Champ" def __str__(self): - return six.text_type(self.name) + return self.name -@python_2_unicode_compatible class EventCommentValue(models.Model): - commentfield = models.ForeignKey(EventCommentField, related_name="values") - registration = models.ForeignKey("EventRegistration", - related_name="comments") + commentfield = models.ForeignKey( + EventCommentField, on_delete=models.CASCADE, + related_name="values", + ) + registration = models.ForeignKey( + "EventRegistration", on_delete=models.CASCADE, + related_name="comments", + ) content = models.TextField("Contenu", blank=True, null=True) def __str__(self): return "Commentaire de %s" % self.commentfield -@python_2_unicode_compatible class EventOption(models.Model): - event = models.ForeignKey(Event, related_name="options") + event = models.ForeignKey( + Event, on_delete=models.CASCADE, + related_name="options", + ) name = models.CharField("Option", max_length=200) multi_choices = models.BooleanField("Choix multiples", default=False) @@ -154,12 +176,14 @@ class EventOption(models.Model): verbose_name = "Option" def __str__(self): - return six.text_type(self.name) + return self.name -@python_2_unicode_compatible class EventOptionChoice(models.Model): - event_option = models.ForeignKey(EventOption, related_name="choices") + event_option = models.ForeignKey( + EventOption, on_delete=models.CASCADE, + related_name="choices", + ) value = models.CharField("Valeur", max_length=200) class Meta: @@ -167,13 +191,12 @@ class EventOptionChoice(models.Model): verbose_name_plural = "Choix" def __str__(self): - return six.text_type(self.value) + return self.value -@python_2_unicode_compatible class EventRegistration(models.Model): - user = models.ForeignKey(User) - event = models.ForeignKey(Event) + user = models.ForeignKey(User, on_delete=models.CASCADE) + event = models.ForeignKey(Event, on_delete=models.CASCADE) options = models.ManyToManyField(EventOptionChoice) filledcomments = models.ManyToManyField(EventCommentField, through=EventCommentValue) @@ -184,11 +207,9 @@ class EventRegistration(models.Model): unique_together = ("user", "event") def __str__(self): - return "Inscription de %s à %s" % (six.text_type(self.user), - six.text_type(self.event.title)) + return "Inscription de {} à {}".format(self.user, self.event.title) -@python_2_unicode_compatible class Survey(models.Model): title = models.CharField("Titre", max_length=200) details = models.TextField("Détails", blank=True) @@ -199,12 +220,14 @@ class Survey(models.Model): verbose_name = "Sondage" def __str__(self): - return six.text_type(self.title) + return self.title -@python_2_unicode_compatible class SurveyQuestion(models.Model): - survey = models.ForeignKey(Survey, related_name="questions") + survey = models.ForeignKey( + Survey, on_delete=models.CASCADE, + related_name="questions", + ) question = models.CharField("Question", max_length=200) multi_answers = models.BooleanField("Choix multiples", default=False) @@ -212,25 +235,26 @@ class SurveyQuestion(models.Model): verbose_name = "Question" def __str__(self): - return six.text_type(self.question) + return self.question -@python_2_unicode_compatible class SurveyQuestionAnswer(models.Model): - survey_question = models.ForeignKey(SurveyQuestion, related_name="answers") + survey_question = models.ForeignKey( + SurveyQuestion, on_delete=models.CASCADE, + related_name="answers", + ) answer = models.CharField("Réponse", max_length=200) class Meta: verbose_name = "Réponse" def __str__(self): - return six.text_type(self.answer) + return self.answer -@python_2_unicode_compatible class SurveyAnswer(models.Model): - user = models.ForeignKey(User) - survey = models.ForeignKey(Survey) + user = models.ForeignKey(User, on_delete=models.CASCADE) + survey = models.ForeignKey(Survey, on_delete=models.CASCADE) answers = models.ManyToManyField(SurveyQuestionAnswer, related_name="selected_by") @@ -244,10 +268,9 @@ class SurveyAnswer(models.Model): self.survey.title) -@python_2_unicode_compatible class CalendarSubscription(models.Model): token = models.UUIDField() - user = models.OneToOneField(User) + user = models.OneToOneField(User, on_delete=models.CASCADE) other_shows = models.ManyToManyField(Spectacle) subscribe_to_events = models.BooleanField(default=True) subscribe_to_my_shows = models.BooleanField(default=True) diff --git a/gestioncof/petits_cours_forms.py b/gestioncof/petits_cours_forms.py index dfb7a263..e8f067bf 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 @@ -12,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 @@ -36,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_models.py b/gestioncof/petits_cours_models.py index 753e8674..820f1292 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 @@ -35,8 +33,11 @@ class PetitCoursSubject(models.Model): class PetitCoursAbility(models.Model): - user = models.ForeignKey(User) - matiere = models.ForeignKey(PetitCoursSubject, verbose_name=_("Matière")) + user = models.ForeignKey(User, on_delete=models.CASCADE) + matiere = models.ForeignKey( + PetitCoursSubject, on_delete=models.CASCADE, + verbose_name=_("Matière"), + ) niveau = models.CharField(_("Niveau"), choices=LEVELS_CHOICES, max_length=choices_length(LEVELS_CHOICES)) @@ -84,7 +85,10 @@ class PetitCoursDemande(models.Model): remarques = models.TextField(_("Remarques et précisions"), blank=True) traitee = models.BooleanField(_("Traitée"), default=False) - traitee_par = models.ForeignKey(User, blank=True, null=True) + traitee_par = models.ForeignKey( + User, on_delete=models.CASCADE, + blank=True, null=True, + ) processed = models.DateTimeField(_("Date de traitement"), blank=True, null=True) created = models.DateTimeField(_("Date de création"), auto_now_add=True) @@ -126,9 +130,15 @@ class PetitCoursDemande(models.Model): class PetitCoursAttribution(models.Model): - user = models.ForeignKey(User) - demande = models.ForeignKey(PetitCoursDemande, verbose_name=_("Demande")) - matiere = models.ForeignKey(PetitCoursSubject, verbose_name=_("Matière")) + user = models.ForeignKey(User, on_delete=models.CASCADE) + demande = models.ForeignKey( + PetitCoursDemande, on_delete=models.CASCADE, + verbose_name=_("Demande"), + ) + matiere = models.ForeignKey( + PetitCoursSubject, on_delete=models.CASCADE, + verbose_name=_("Matière"), + ) date = models.DateTimeField(_("Date d'attribution"), auto_now_add=True) rank = models.IntegerField("Rang dans l'email") selected = models.BooleanField(_("Sélectionné par le demandeur"), @@ -145,8 +155,11 @@ class PetitCoursAttribution(models.Model): class PetitCoursAttributionCounter(models.Model): - user = models.ForeignKey(User) - matiere = models.ForeignKey(PetitCoursSubject, verbose_name=_("Matiere")) + user = models.ForeignKey(User, on_delete=models.CASCADE) + matiere = models.ForeignKey( + PetitCoursSubject, on_delete=models.CASCADE, + verbose_name=_("Matiere"), + ) count = models.IntegerField("Nombre d'envois", default=0) @classmethod @@ -157,14 +170,16 @@ class PetitCoursAttributionCounter(models.Model): compteurs de tout le monde. """ counter, created = cls.objects.get_or_create( - user=user, matiere=matiere) + user=user, + matiere=matiere, + ) if created: mincount = ( cls.objects.filter(matiere=matiere).exclude(user=user) .aggregate(Min('count')) ['count__min'] ) - counter.count = mincount + counter.count = mincount or 0 counter.save() return counter diff --git a/gestioncof/petits_cours_views.py b/gestioncof/petits_cours_views.py index 3fa0dc57..6b8c8610 100644 --- a/gestioncof/petits_cours_views.py +++ b/gestioncof/petits_cours_views.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- - import json -from datetime import datetime from custommail.shortcuts import render_custom_mail from django.shortcuts import render, get_object_or_404, redirect @@ -12,15 +9,16 @@ from django.views.decorators.csrf import csrf_exempt from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib import messages +from django.db import transaction +from django.utils import timezone from gestioncof.models import CofProfile from gestioncof.petits_cours_models import ( PetitCoursDemande, PetitCoursAttribution, PetitCoursAttributionCounter, - PetitCoursAbility, PetitCoursSubject + PetitCoursAbility ) from gestioncof.petits_cours_forms import DemandeForm, MatieresFormSet from gestioncof.decorators import buro_required -from gestioncof.shared import lock_table, unlock_tables class DemandeListView(ListView): @@ -44,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 @@ -274,20 +272,20 @@ def _traitement_post(request, demande): headers={'Reply-To': replyto})) connection = mail.get_connection(fail_silently=False) connection.send_messages(mails_to_send) - lock_table(PetitCoursAttributionCounter, PetitCoursAttribution, User) - for matiere in proposals: - for rank, user in enumerate(proposals[matiere]): - counter = PetitCoursAttributionCounter.objects.get(user=user, - matiere=matiere) - counter.count += 1 - counter.save() - attrib = PetitCoursAttribution(user=user, matiere=matiere, - demande=demande, rank=rank + 1) - attrib.save() - unlock_tables() + with transaction.atomic(): + for matiere in proposals: + for rank, user in enumerate(proposals[matiere]): + counter = PetitCoursAttributionCounter.objects.get( + user=user, matiere=matiere + ) + counter.count += 1 + counter.save() + attrib = PetitCoursAttribution(user=user, matiere=matiere, + demande=demande, rank=rank + 1) + attrib.save() demande.traitee = True demande.traitee_par = request.user - demande.processed = datetime.now() + demande.processed = timezone.now() demande.save() return render(request, "gestioncof/traitement_demande_petit_cours_success.html", @@ -309,17 +307,15 @@ def inscription(request): profile.petits_cours_accept = "receive_proposals" in request.POST profile.petits_cours_remarques = request.POST["remarques"] profile.save() - lock_table(PetitCoursAttributionCounter, PetitCoursAbility, User, - PetitCoursSubject) - abilities = ( - PetitCoursAbility.objects.filter(user=request.user).all() - ) - for ability in abilities: - PetitCoursAttributionCounter.get_uptodate( - ability.user, - ability.matiere + with transaction.atomic(): + abilities = ( + PetitCoursAbility.objects.filter(user=request.user).all() ) - unlock_tables() + for ability in abilities: + PetitCoursAttributionCounter.get_uptodate( + ability.user, + ability.matiere + ) success = True formset = MatieresFormSet(instance=request.user) else: diff --git a/gestioncof/shared.py b/gestioncof/shared.py index 63aceae5..fdab9a45 100644 --- a/gestioncof/shared.py +++ b/gestioncof/shared.py @@ -1,69 +1,25 @@ -# -*- coding: utf-8 -*- - -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - -from django.contrib.sites.models import Site from django.conf import settings +from django.contrib.sites.models import Site + from django_cas_ng.backends import CASBackend -from django_cas_ng.utils import get_cas_client -from django.contrib.auth import get_user_model -from django.db import connection from gestioncof.models import CofProfile -User = get_user_model() - class COFCASBackend(CASBackend): - def authenticate_cas(self, ticket, service, request): - """Verifies CAS ticket and gets or creates User object""" - - client = get_cas_client(service_url=service) - username, attributes, _ = client.verify_ticket(ticket) - if attributes: - request.session['attributes'] = attributes - if not username: - return None + def clean_username(self, username): # Le CAS de l'ENS accepte les logins avec des espaces au début # et à la fin, ainsi qu’avec une casse variable. On normalise pour # éviter les doublons. - username = username.strip().lower() + return username.strip().lower() - profiles = CofProfile.objects.filter(login_clipper=username) - if len(profiles) > 0: - profile = profiles.order_by('-is_cof')[0] - user = profile.user - return user - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - # user will have an "unusable" password - user = User.objects.create_user(username, '') - user.save() - return user - - def authenticate(self, ticket, service, request): - """Authenticates CAS ticket and retrieves user data""" - user = self.authenticate_cas(ticket, service, request) - if user is None: - return user - try: - profile = user.profile - except CofProfile.DoesNotExist: - profile, created = CofProfile.objects.get_or_create(user=user) - profile.save() - if not profile.login_clipper: - profile.login_clipper = user.username - profile.save() - if not user.email: - user.email = settings.CAS_EMAIL_FORMAT % profile.login_clipper - user.save() - if profile.is_buro and not user.is_staff: - user.is_staff = True - user.save() + def configure_user(self, user): + clipper = user.username + user.profile.login_clipper = clipper + user.profile.save() + user.email = settings.CAS_EMAIL_FORMAT % clipper + user.save() return user @@ -74,25 +30,3 @@ def context_processor(request): "site": Site.objects.get_current(), } return data - - -def lock_table(*models): - query = "LOCK TABLES " - for i, model in enumerate(models): - table = model._meta.db_table - if i > 0: - query += ", " - query += "%s WRITE" % table - cursor = connection.cursor() - cursor.execute(query) - row = cursor.fetchone() - return row - - -def unlock_tables(*models): - cursor = connection.cursor() - cursor.execute("UNLOCK TABLES") - row = cursor.fetchone() - return row - -unlock_table = unlock_tables diff --git a/gestioncof/signals.py b/gestioncof/signals.py new file mode 100644 index 00000000..11cb55fc --- /dev/null +++ b/gestioncof/signals.py @@ -0,0 +1,23 @@ +from django.contrib import messages +from django.contrib.auth.signals import user_logged_in +from django.dispatch import receiver +from django.utils.translation import ugettext_lazy as _ + +from django_cas_ng.signals import cas_user_authenticated + + +@receiver(user_logged_in) +def messages_on_out_login(request, user, **kwargs): + if user.backend.startswith('django.contrib.auth'): + msg = _('Connexion à GestioCOF réussie. Bienvenue {}.').format( + user.get_short_name(), + ) + messages.success(request, msg) + + +@receiver(cas_user_authenticated) +def mesagges_on_cas_login(request, user, **kwargs): + msg = _('Connexion à GestioCOF par CAS réussie. Bienvenue {}.').format( + user.get_short_name(), + ) + messages.success(request, msg) diff --git a/gestioncof/static/css/cof.css b/gestioncof/static/css/cof.css index fda55d98..84c81d60 100644 --- a/gestioncof/static/css/cof.css +++ b/gestioncof/static/css/cof.css @@ -40,8 +40,9 @@ a { background: transparent; } - - +div.empty-form { + padding-bottom: 2em; +} a:hover { color: #444; @@ -341,10 +342,12 @@ fieldset legend { font-weight: 700; font-size: large; color:#DE826B; + padding-bottom: .5em; } #main-content h4 { color:#DE826B; + padding-bottom: .5em; } #main-content h2, @@ -778,6 +781,17 @@ header .open > .dropdown-toggle.btn-default { border-color: white; } +/* Announcements banner ------------------ */ + +#banner { + background-color: #d86b01; + width: 100%; + text-align: center; + padding: 10px; + color: white; + font-size: larger; +} + /* FORMS --------------------------------- */ @@ -1126,3 +1140,14 @@ p.help-block { margin: 5px auto; width: 90%; } + +div.bg-info { + border-radius: 3px; + padding: 0.3em 1em; + margin-left: 1em; + margin-right: 1em; +} + +.bootstrap-form-reduce > .form-group { + margin-top: -16px; +} diff --git a/gestioncof/templates/admin/base_site.html b/gestioncof/templates/admin/base_site.html deleted file mode 100644 index 03fd0d17..00000000 --- a/gestioncof/templates/admin/base_site.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "admin/base.html" %} - -{% block extrahead %} - - {% include 'autocomplete_light/static.html' %} -{% endblock %} diff --git a/gestioncof/templates/admin/index.html b/gestioncof/templates/admin/index.html deleted file mode 100644 index 965c71fa..00000000 --- a/gestioncof/templates/admin/index.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends "admin/base_site.html" %} - - -{% load i18n grp_tags log %} - - -{% block javascripts %} - {{ block.super }} -{% endblock %} - - -{% block breadcrumbs %} -
      -
    • {% trans "Home" %}
    • -
    -{% endblock %} -{% block content_title %} - {% if title %} -

    {{ title }}

    - {% endif %} -{% endblock %} - - -{% block content %} -
    -
    - - {% for app in app_list %} -
    -

    {% trans app.name %}

    - {% for model in app.models %} -
    - {% if model.perms.change %}{{ model.name }}{% else %}{{ model.name }}{% endif %} - {% if model.perms.add or model.perms.change %} - - {% endif %} -
    - {% endfor %} -
    - {% empty %} -

    {% trans "You don´t have permission to edit anything." %}

    - {% endfor %} -
    -
    -
    -

    {% trans 'Recent Actions' %}

    -
    -

    {% trans 'My Actions' %}

    - {% get_admin_log 20 as admin_log for_user user %} - {% if not admin_log %} -

    {% trans 'None available' %}

    - {% else %} -
      - {% for entry in admin_log %} -
    • - {% if entry.is_deletion %} - {{ entry.object_repr }} - {% else %} - {{ entry.object_repr }} - {% endif %} - {% filter capfirst %}{% trans entry.content_type.name %}{% endfilter %} -
    • - {% endfor %} -
    - {% endif %} -
    -
    -
    -
    -{% endblock %} - diff --git a/gestioncof/templates/base.html b/gestioncof/templates/base.html index 6c570ae8..41880c61 100644 --- a/gestioncof/templates/base.html +++ b/gestioncof/templates/base.html @@ -8,13 +8,13 @@ - {# CSS #} + {# CSS #} - {# JS #} + {# JS #} {% block extra_head %}{% endblock %} diff --git a/gestioncof/templates/demande-petit-cours-raw.html b/gestioncof/templates/demande-petit-cours-raw.html index f9bafd8e..a218a0e0 100644 --- a/gestioncof/templates/demande-petit-cours-raw.html +++ b/gestioncof/templates/demande-petit-cours-raw.html @@ -7,7 +7,7 @@ {% if success %}

    Votre demande a été enregistrée avec succès !

    {% else %} -
    + {% csrf_token %} {{ form | bootstrap }} diff --git a/gestioncof/templates/demande-petit-cours.html b/gestioncof/templates/demande-petit-cours.html index 3a447e3a..7a41f160 100644 --- a/gestioncof/templates/demande-petit-cours.html +++ b/gestioncof/templates/demande-petit-cours.html @@ -5,7 +5,7 @@ {% if success %}

    Votre demande a été enregistrée avec succès !

    {% else %} - + {% csrf_token %}
    {{ form.as_table }} 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 }} diff --git a/gestioncof/templates/gestioncof/banner_update.html b/gestioncof/templates/gestioncof/banner_update.html new file mode 100644 index 00000000..b2432eae --- /dev/null +++ b/gestioncof/templates/gestioncof/banner_update.html @@ -0,0 +1,23 @@ +{% extends "base_title.html" %} +{% load bootstrap %} +{% load i18n %} + +{% block page_size %}col-sm-8{%endblock%} + +{% block realcontent %} +

    {% trans "Global configuration" %}

    + +
    + {% csrf_token %} + + {% for field in form %} + {{ field | bootstrap }} + {% endfor %} + +
    +
    + +
    + +{% endblock %} diff --git a/gestioncof/templates/gestioncof/base_header.html b/gestioncof/templates/gestioncof/base_header.html index a7e29eb7..e5f757a7 100644 --- a/gestioncof/templates/gestioncof/base_header.html +++ b/gestioncof/templates/gestioncof/base_header.html @@ -3,7 +3,7 @@ {% block content %}
    + +{% if config.gestion_banner %} + +{% endif %} + {% if messages %} {% for message in messages %}
    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/templates/gestioncof/event.html b/gestioncof/templates/gestioncof/event.html index 52f893db..f388bc25 100644 --- a/gestioncof/templates/gestioncof/event.html +++ b/gestioncof/templates/gestioncof/event.html @@ -5,7 +5,7 @@ {% if event.details %}

      {{ event.details }}

      {% endif %} -
      + {% csrf_token %} {{ form.as_p }} 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/registration_form.html b/gestioncof/templates/gestioncof/registration_form.html index 8668152b..37f24cff 100644 --- a/gestioncof/templates/gestioncof/registration_form.html +++ b/gestioncof/templates/gestioncof/registration_form.html @@ -7,7 +7,7 @@ {% else %}

      Inscription d'un nouveau compte (extérieur ?)

      {% endif %} - + {% csrf_token %}
    {{ user_form | bootstrap }} 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/gestioncof/utile_cof.html b/gestioncof/templates/gestioncof/utile_cof.html index ae024949..637055c5 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/templates/home.html b/gestioncof/templates/home.html index 5f783a48..b14c0a82 100644 --- a/gestioncof/templates/home.html +++ b/gestioncof/templates/home.html @@ -1,4 +1,5 @@ {% extends "gestioncof/base_header.html" %} +{% load wagtailcore_tags %} {% block homelink %} {% endblock %} @@ -13,7 +14,7 @@

    @@ -23,7 +24,7 @@
    @@ -42,9 +43,10 @@
  • État des demandes
  • {% else %}
  • Mes places
  • -
  • Revendre une place
  • -
  • S'inscrire à BdA-Revente
  • -
  • Places disponibles immédiatement
  • +
  • Gérer les places que je revends
  • +
  • Voir les reventes en cours
  • +
  • Indiquer les spectacles qui m'intéressent
  • +
  • Places disponibles immédiatement
  • {% endif %} {% endfor %} @@ -55,7 +57,8 @@

    K-Fêt

    @@ -118,8 +121,8 @@

    Liens utiles

    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" %} 0 ) { + cacheIndex += span; + } + cacheIndex++; + } + // add the row data to the end + cells[ c.columns ] = rowData; + // update cache + c.cache[ tbodyIndex ].normalized[ order ] = cells; + } + // resort using current settings + ts.checkResort( c, resort, callback ); + } + }, + + updateCache : function( c, callback, $tbodies ) { + // rebuild parsers + if ( !( c.parsers && c.parsers.length ) ) { + ts.setupParsers( c, $tbodies ); + } + // rebuild the cache map + ts.buildCache( c, callback, $tbodies ); + }, + + // init flag (true) used by pager plugin to prevent widget application + // renamed from appendToTable + appendCache : function( c, init ) { + var parsed, totalRows, $tbody, $curTbody, rowIndex, tbodyIndex, appendTime, + table = c.table, + wo = c.widgetOptions, + $tbodies = c.$tbodies, + rows = [], + cache = c.cache; + // empty table - fixes #206/#346 + if ( ts.isEmptyObject( cache ) ) { + // run pager appender in case the table was just emptied + return c.appender ? c.appender( table, rows ) : + table.isUpdating ? c.$table.triggerHandler( 'updateComplete', table ) : ''; // Fixes #532 + } + if ( c.debug ) { + appendTime = new Date(); + } + for ( tbodyIndex = 0; tbodyIndex < $tbodies.length; tbodyIndex++ ) { + $tbody = $tbodies.eq( tbodyIndex ); + if ( $tbody.length ) { + // detach tbody for manipulation + $curTbody = ts.processTbody( table, $tbody, true ); + parsed = cache[ tbodyIndex ].normalized; + totalRows = parsed.length; + for ( rowIndex = 0; rowIndex < totalRows; rowIndex++ ) { + rows[rows.length] = parsed[ rowIndex ][ c.columns ].$row; + // removeRows used by the pager plugin; don't render if using ajax - fixes #411 + if ( !c.appender || ( c.pager && ( !c.pager.removeRows || !wo.pager_removeRows ) && !c.pager.ajax ) ) { + $curTbody.append( parsed[ rowIndex ][ c.columns ].$row ); + } + } + // restore tbody + ts.processTbody( table, $curTbody, false ); + } + } + if ( c.appender ) { + c.appender( table, rows ); + } + if ( c.debug ) { + console.log( 'Rebuilt table' + ts.benchmark( appendTime ) ); + } + // apply table widgets; but not before ajax completes + if ( !init && !c.appender ) { + ts.applyWidget( table ); + } + if ( table.isUpdating ) { + c.$table.triggerHandler( 'updateComplete', table ); + } + }, + + commonUpdate : function( c, resort, callback ) { + // remove rows/elements before update + c.$table.find( c.selectorRemove ).remove(); + // rebuild parsers + ts.setupParsers( c ); + // rebuild the cache map + ts.buildCache( c ); + ts.checkResort( c, resort, callback ); + }, + + /* + ▄█████ ▄████▄ █████▄ ██████ ██ █████▄ ▄████▄ + ▀█▄ ██ ██ ██▄▄██ ██ ██ ██ ██ ██ ▄▄▄ + ▀█▄ ██ ██ ██▀██ ██ ██ ██ ██ ██ ▀██ + █████▀ ▀████▀ ██ ██ ██ ██ ██ ██ ▀████▀ + */ + initSort : function( c, cell, event ) { + if ( c.table.isUpdating ) { + // let any updates complete before initializing a sort + return setTimeout( function(){ + ts.initSort( c, cell, event ); + }, 50 ); + } + + var arry, indx, headerIndx, dir, temp, tmp, $header, + notMultiSort = !event[ c.sortMultiSortKey ], + table = c.table, + len = c.$headers.length, + // get current column index; *always* stored on th/td + $th = ts.getHeaderCell( $( cell ) ), + col = parseInt( $th.attr( 'data-column' ), 10 ), + order = c.sortVars[ col ].order; + // Only call sortStart if sorting is enabled + c.$table.triggerHandler( 'sortStart', table ); + // get current column sort order + tmp = ( c.sortVars[ col ].count + 1 ) % order.length; + c.sortVars[ col ].count = event[ c.sortResetKey ] ? 2 : tmp; + // reset all sorts on non-current column - issue #30 + if ( c.sortRestart ) { + for ( headerIndx = 0; headerIndx < len; headerIndx++ ) { + $header = c.$headers.eq( headerIndx ); + tmp = parseInt( $header.attr( 'data-column' ), 10 ); + // only reset counts on columns that weren't just clicked on and if not included in a multisort + if ( col !== tmp && ( notMultiSort || $header.hasClass( ts.css.sortNone ) ) ) { + c.sortVars[ tmp ].count = -1; + } + } + } + // user only wants to sort on one column + if ( notMultiSort ) { + // flush the sort list + c.sortList = []; + c.last.sortList = []; + if ( c.sortForce !== null ) { + arry = c.sortForce; + for ( indx = 0; indx < arry.length; indx++ ) { + if ( arry[ indx ][ 0 ] !== col ) { + c.sortList[ c.sortList.length ] = arry[ indx ]; + } + } + } + // add column to sort list + dir = order[ c.sortVars[ col ].count ]; + if ( dir < 2 ) { + c.sortList[ c.sortList.length ] = [ col, dir ]; + // add other columns if header spans across multiple + if ( cell.colSpan > 1 ) { + for ( indx = 1; indx < cell.colSpan; indx++ ) { + c.sortList[ c.sortList.length ] = [ col + indx, dir ]; + // update count on columns in colSpan + c.sortVars[ col + indx ].count = $.inArray( dir, order ); + } + } + } + // multi column sorting + } else { + // get rid of the sortAppend before adding more - fixes issue #115 & #523 + c.sortList = $.extend( [], c.last.sortList ); + + // the user has clicked on an already sorted column + if ( ts.isValueInArray( col, c.sortList ) >= 0 ) { + // reverse the sorting direction + for ( indx = 0; indx < c.sortList.length; indx++ ) { + tmp = c.sortList[ indx ]; + if ( tmp[ 0 ] === col ) { + // order.count seems to be incorrect when compared to cell.count + tmp[ 1 ] = order[ c.sortVars[ col ].count ]; + if ( tmp[1] === 2 ) { + c.sortList.splice( indx, 1 ); + c.sortVars[ col ].count = -1; + } + } + } + } else { + // add column to sort list array + dir = order[ c.sortVars[ col ].count ]; + if ( dir < 2 ) { + c.sortList[ c.sortList.length ] = [ col, dir ]; + // add other columns if header spans across multiple + if ( cell.colSpan > 1 ) { + for ( indx = 1; indx < cell.colSpan; indx++ ) { + c.sortList[ c.sortList.length ] = [ col + indx, dir ]; + // update count on columns in colSpan + c.sortVars[ col + indx ].count = $.inArray( dir, order ); + } + } + } + } + } + // save sort before applying sortAppend + c.last.sortList = $.extend( [], c.sortList ); + if ( c.sortList.length && c.sortAppend ) { + arry = $.isArray( c.sortAppend ) ? c.sortAppend : c.sortAppend[ c.sortList[ 0 ][ 0 ] ]; + if ( !ts.isEmptyObject( arry ) ) { + for ( indx = 0; indx < arry.length; indx++ ) { + if ( arry[ indx ][ 0 ] !== col && ts.isValueInArray( arry[ indx ][ 0 ], c.sortList ) < 0 ) { + dir = arry[ indx ][ 1 ]; + temp = ( '' + dir ).match( /^(a|d|s|o|n)/ ); + if ( temp ) { + tmp = c.sortList[ 0 ][ 1 ]; + switch ( temp[ 0 ] ) { + case 'd' : + dir = 1; + break; + case 's' : + dir = tmp; + break; + case 'o' : + dir = tmp === 0 ? 1 : 0; + break; + case 'n' : + dir = ( tmp + 1 ) % order.length; + break; + default: + dir = 0; + break; + } + } + c.sortList[ c.sortList.length ] = [ arry[ indx ][ 0 ], dir ]; + } + } + } + } + // sortBegin event triggered immediately before the sort + c.$table.triggerHandler( 'sortBegin', table ); + // setTimeout needed so the processing icon shows up + setTimeout( function() { + // set css for headers + ts.setHeadersCss( c ); + ts.multisort( c ); + ts.appendCache( c ); + c.$table.triggerHandler( 'sortBeforeEnd', table ); + c.$table.triggerHandler( 'sortEnd', table ); + }, 1 ); + }, + + // sort multiple columns + multisort : function( c ) { /*jshint loopfunc:true */ + var tbodyIndex, sortTime, colMax, rows, tmp, + table = c.table, + sorter = [], + dir = 0, + textSorter = c.textSorter || '', + sortList = c.sortList, + sortLen = sortList.length, + len = c.$tbodies.length; + if ( c.serverSideSorting || ts.isEmptyObject( c.cache ) ) { + // empty table - fixes #206/#346 + return; + } + if ( c.debug ) { sortTime = new Date(); } + // cache textSorter to optimize speed + if ( typeof textSorter === 'object' ) { + colMax = c.columns; + while ( colMax-- ) { + tmp = ts.getColumnData( table, textSorter, colMax ); + if ( typeof tmp === 'function' ) { + sorter[ colMax ] = tmp; + } + } + } + for ( tbodyIndex = 0; tbodyIndex < len; tbodyIndex++ ) { + colMax = c.cache[ tbodyIndex ].colMax; + rows = c.cache[ tbodyIndex ].normalized; + + rows.sort( function( a, b ) { + var sortIndex, num, col, order, sort, x, y; + // rows is undefined here in IE, so don't use it! + for ( sortIndex = 0; sortIndex < sortLen; sortIndex++ ) { + col = sortList[ sortIndex ][ 0 ]; + order = sortList[ sortIndex ][ 1 ]; + // sort direction, true = asc, false = desc + dir = order === 0; + + if ( c.sortStable && a[ col ] === b[ col ] && sortLen === 1 ) { + return a[ c.columns ].order - b[ c.columns ].order; + } + + // fallback to natural sort since it is more robust + num = /n/i.test( ts.getSortType( c.parsers, col ) ); + if ( num && c.strings[ col ] ) { + // sort strings in numerical columns + if ( typeof ( ts.string[ c.strings[ col ] ] ) === 'boolean' ) { + num = ( dir ? 1 : -1 ) * ( ts.string[ c.strings[ col ] ] ? -1 : 1 ); + } else { + num = ( c.strings[ col ] ) ? ts.string[ c.strings[ col ] ] || 0 : 0; + } + // fall back to built-in numeric sort + // var sort = $.tablesorter['sort' + s]( a[col], b[col], dir, colMax[col], table ); + sort = c.numberSorter ? c.numberSorter( a[ col ], b[ col ], dir, colMax[ col ], table ) : + ts[ 'sortNumeric' + ( dir ? 'Asc' : 'Desc' ) ]( a[ col ], b[ col ], num, colMax[ col ], col, c ); + } else { + // set a & b depending on sort direction + x = dir ? a : b; + y = dir ? b : a; + // text sort function + if ( typeof textSorter === 'function' ) { + // custom OVERALL text sorter + sort = textSorter( x[ col ], y[ col ], dir, col, table ); + } else if ( typeof sorter[ col ] === 'function' ) { + // custom text sorter for a SPECIFIC COLUMN + sort = sorter[ col ]( x[ col ], y[ col ], dir, col, table ); + } else { + // fall back to natural sort + sort = ts[ 'sortNatural' + ( dir ? 'Asc' : 'Desc' ) ]( a[ col ], b[ col ], col, c ); + } + } + if ( sort ) { return sort; } + } + return a[ c.columns ].order - b[ c.columns ].order; + }); + } + if ( c.debug ) { + console.log( 'Applying sort ' + sortList.toString() + ts.benchmark( sortTime ) ); + } + }, + + resortComplete : function( c, callback ) { + if ( c.table.isUpdating ) { + c.$table.triggerHandler( 'updateComplete', c.table ); + } + if ( $.isFunction( callback ) ) { + callback( c.table ); + } + }, + + checkResort : function( c, resort, callback ) { + var sortList = $.isArray( resort ) ? resort : c.sortList, + // if no resort parameter is passed, fallback to config.resort (true by default) + resrt = typeof resort === 'undefined' ? c.resort : resort; + // don't try to resort if the table is still processing + // this will catch spamming of the updateCell method + if ( resrt !== false && !c.serverSideSorting && !c.table.isProcessing ) { + if ( sortList.length ) { + ts.sortOn( c, sortList, function() { + ts.resortComplete( c, callback ); + }, true ); + } else { + ts.sortReset( c, function() { + ts.resortComplete( c, callback ); + ts.applyWidget( c.table, false ); + } ); + } + } else { + ts.resortComplete( c, callback ); + ts.applyWidget( c.table, false ); + } + }, + + sortOn : function( c, list, callback, init ) { + var table = c.table; + c.$table.triggerHandler( 'sortStart', table ); + // update header count index + ts.updateHeaderSortCount( c, list ); + // set css for headers + ts.setHeadersCss( c ); + // fixes #346 + if ( c.delayInit && ts.isEmptyObject( c.cache ) ) { + ts.buildCache( c ); + } + c.$table.triggerHandler( 'sortBegin', table ); + // sort the table and append it to the dom + ts.multisort( c ); + ts.appendCache( c, init ); + c.$table.triggerHandler( 'sortBeforeEnd', table ); + c.$table.triggerHandler( 'sortEnd', table ); + ts.applyWidget( table ); + if ( $.isFunction( callback ) ) { + callback( table ); + } + }, + + sortReset : function( c, callback ) { + c.sortList = []; + ts.setHeadersCss( c ); + ts.multisort( c ); + ts.appendCache( c ); + var indx; + for (indx = 0; indx < c.columns; indx++) { + c.sortVars[ indx ].count = -1; + } + if ( $.isFunction( callback ) ) { + callback( c.table ); + } + }, + + getSortType : function( parsers, column ) { + return ( parsers && parsers[ column ] ) ? parsers[ column ].type || '' : ''; + }, + + getOrder : function( val ) { + // look for 'd' in 'desc' order; return true + return ( /^d/i.test( val ) || val === 1 ); + }, + + // Natural sort - https://github.com/overset/javascript-natural-sort (date sorting removed) + sortNatural : function( a, b ) { + if ( a === b ) { return 0; } + a = a.toString(); + b = b.toString(); + var aNum, bNum, aFloat, bFloat, indx, max, + regex = ts.regex; + // first try and sort Hex codes + if ( regex.hex.test( b ) ) { + aNum = parseInt( ( a || '' ).match( regex.hex ), 16 ); + bNum = parseInt( ( b || '' ).match( regex.hex ), 16 ); + if ( aNum < bNum ) { return -1; } + if ( aNum > bNum ) { return 1; } + } + // chunk/tokenize + aNum = ( a || '' ).replace( regex.chunk, '\\0$1\\0' ).replace( regex.chunks, '' ).split( '\\0' ); + bNum = ( b || '' ).replace( regex.chunk, '\\0$1\\0' ).replace( regex.chunks, '' ).split( '\\0' ); + max = Math.max( aNum.length, bNum.length ); + // natural sorting through split numeric strings and default strings + for ( indx = 0; indx < max; indx++ ) { + // find floats not starting with '0', string or 0 if not defined + aFloat = isNaN( aNum[ indx ] ) ? aNum[ indx ] || 0 : parseFloat( aNum[ indx ] ) || 0; + bFloat = isNaN( bNum[ indx ] ) ? bNum[ indx ] || 0 : parseFloat( bNum[ indx ] ) || 0; + // handle numeric vs string comparison - number < string - (Kyle Adams) + if ( isNaN( aFloat ) !== isNaN( bFloat ) ) { return isNaN( aFloat ) ? 1 : -1; } + // rely on string comparison if different types - i.e. '02' < 2 != '02' < '2' + if ( typeof aFloat !== typeof bFloat ) { + aFloat += ''; + bFloat += ''; + } + if ( aFloat < bFloat ) { return -1; } + if ( aFloat > bFloat ) { return 1; } + } + return 0; + }, + + sortNaturalAsc : function( a, b, col, c ) { + if ( a === b ) { return 0; } + var empty = ts.string[ ( c.empties[ col ] || c.emptyTo ) ]; + if ( a === '' && empty !== 0 ) { return typeof empty === 'boolean' ? ( empty ? -1 : 1 ) : -empty || -1; } + if ( b === '' && empty !== 0 ) { return typeof empty === 'boolean' ? ( empty ? 1 : -1 ) : empty || 1; } + return ts.sortNatural( a, b ); + }, + + sortNaturalDesc : function( a, b, col, c ) { + if ( a === b ) { return 0; } + var empty = ts.string[ ( c.empties[ col ] || c.emptyTo ) ]; + if ( a === '' && empty !== 0 ) { return typeof empty === 'boolean' ? ( empty ? -1 : 1 ) : empty || 1; } + if ( b === '' && empty !== 0 ) { return typeof empty === 'boolean' ? ( empty ? 1 : -1 ) : -empty || -1; } + return ts.sortNatural( b, a ); + }, + + // basic alphabetical sort + sortText : function( a, b ) { + return a > b ? 1 : ( a < b ? -1 : 0 ); + }, + + // return text string value by adding up ascii value + // so the text is somewhat sorted when using a digital sort + // this is NOT an alphanumeric sort + getTextValue : function( val, num, max ) { + if ( max ) { + // make sure the text value is greater than the max numerical value (max) + var indx, + len = val ? val.length : 0, + n = max + num; + for ( indx = 0; indx < len; indx++ ) { + n += val.charCodeAt( indx ); + } + return num * n; + } + return 0; + }, + + sortNumericAsc : function( a, b, num, max, col, c ) { + if ( a === b ) { return 0; } + var empty = ts.string[ ( c.empties[ col ] || c.emptyTo ) ]; + if ( a === '' && empty !== 0 ) { return typeof empty === 'boolean' ? ( empty ? -1 : 1 ) : -empty || -1; } + if ( b === '' && empty !== 0 ) { return typeof empty === 'boolean' ? ( empty ? 1 : -1 ) : empty || 1; } + if ( isNaN( a ) ) { a = ts.getTextValue( a, num, max ); } + if ( isNaN( b ) ) { b = ts.getTextValue( b, num, max ); } + return a - b; + }, + + sortNumericDesc : function( a, b, num, max, col, c ) { + if ( a === b ) { return 0; } + var empty = ts.string[ ( c.empties[ col ] || c.emptyTo ) ]; + if ( a === '' && empty !== 0 ) { return typeof empty === 'boolean' ? ( empty ? -1 : 1 ) : empty || 1; } + if ( b === '' && empty !== 0 ) { return typeof empty === 'boolean' ? ( empty ? 1 : -1 ) : -empty || -1; } + if ( isNaN( a ) ) { a = ts.getTextValue( a, num, max ); } + if ( isNaN( b ) ) { b = ts.getTextValue( b, num, max ); } + return b - a; + }, + + sortNumeric : function( a, b ) { + return a - b; + }, + + /* + ██ ██ ██ ██ █████▄ ▄████▄ ██████ ██████ ▄█████ + ██ ██ ██ ██ ██ ██ ██ ▄▄▄ ██▄▄ ██ ▀█▄ + ██ ██ ██ ██ ██ ██ ██ ▀██ ██▀▀ ██ ▀█▄ + ███████▀ ██ █████▀ ▀████▀ ██████ ██ █████▀ + */ + addWidget : function( widget ) { + if ( widget.id && !ts.isEmptyObject( ts.getWidgetById( widget.id ) ) ) { + console.warn( '"' + widget.id + '" widget was loaded more than once!' ); + } + ts.widgets[ ts.widgets.length ] = widget; + }, + + hasWidget : function( $table, name ) { + $table = $( $table ); + return $table.length && $table[ 0 ].config && $table[ 0 ].config.widgetInit[ name ] || false; + }, + + getWidgetById : function( name ) { + var indx, widget, + len = ts.widgets.length; + for ( indx = 0; indx < len; indx++ ) { + widget = ts.widgets[ indx ]; + if ( widget && widget.id && widget.id.toLowerCase() === name.toLowerCase() ) { + return widget; + } + } + }, + + applyWidgetOptions : function( table ) { + var indx, widget, wo, + c = table.config, + len = c.widgets.length; + if ( len ) { + for ( indx = 0; indx < len; indx++ ) { + widget = ts.getWidgetById( c.widgets[ indx ] ); + if ( widget && widget.options ) { + wo = $.extend( true, {}, widget.options ); + c.widgetOptions = $.extend( true, wo, c.widgetOptions ); + // add widgetOptions to defaults for option validator + $.extend( true, ts.defaults.widgetOptions, widget.options ); + } + } + } + }, + + addWidgetFromClass : function( table ) { + var len, indx, + c = table.config, + // look for widgets to apply from table class + // don't match from 'ui-widget-content'; use \S instead of \w to include widgets + // with dashes in the name, e.g. "widget-test-2" extracts out "test-2" + regex = '^' + c.widgetClass.replace( ts.regex.templateName, '(\\S+)+' ) + '$', + widgetClass = new RegExp( regex, 'g' ), + // split up table class (widget id's can include dashes) - stop using match + // otherwise only one widget gets extracted, see #1109 + widgets = ( table.className || '' ).split( ts.regex.spaces ); + if ( widgets.length ) { + len = widgets.length; + for ( indx = 0; indx < len; indx++ ) { + if ( widgets[ indx ].match( widgetClass ) ) { + c.widgets[ c.widgets.length ] = widgets[ indx ].replace( widgetClass, '$1' ); + } + } + } + }, + + applyWidgetId : function( table, id, init ) { + table = $(table)[0]; + var applied, time, name, + c = table.config, + wo = c.widgetOptions, + widget = ts.getWidgetById( id ); + if ( widget ) { + name = widget.id; + applied = false; + // add widget name to option list so it gets reapplied after sorting, filtering, etc + if ( $.inArray( name, c.widgets ) < 0 ) { + c.widgets[ c.widgets.length ] = name; + } + if ( c.debug ) { time = new Date(); } + + if ( init || !( c.widgetInit[ name ] ) ) { + // set init flag first to prevent calling init more than once (e.g. pager) + c.widgetInit[ name ] = true; + if ( table.hasInitialized ) { + // don't reapply widget options on tablesorter init + ts.applyWidgetOptions( table ); + } + if ( typeof widget.init === 'function' ) { + applied = true; + if ( c.debug ) { + console[ console.group ? 'group' : 'log' ]( 'Initializing ' + name + ' widget' ); + } + widget.init( table, widget, c, wo ); + } + } + if ( !init && typeof widget.format === 'function' ) { + applied = true; + if ( c.debug ) { + console[ console.group ? 'group' : 'log' ]( 'Updating ' + name + ' widget' ); + } + widget.format( table, c, wo, false ); + } + if ( c.debug ) { + if ( applied ) { + console.log( 'Completed ' + ( init ? 'initializing ' : 'applying ' ) + name + ' widget' + ts.benchmark( time ) ); + if ( console.groupEnd ) { console.groupEnd(); } + } + } + } + }, + + applyWidget : function( table, init, callback ) { + table = $( table )[ 0 ]; // in case this is called externally + var indx, len, names, widget, time, + c = table.config, + widgets = []; + // prevent numerous consecutive widget applications + if ( init !== false && table.hasInitialized && ( table.isApplyingWidgets || table.isUpdating ) ) { + return; + } + if ( c.debug ) { time = new Date(); } + ts.addWidgetFromClass( table ); + // prevent "tablesorter-ready" from firing multiple times in a row + clearTimeout( c.timerReady ); + if ( c.widgets.length ) { + table.isApplyingWidgets = true; + // ensure unique widget ids + c.widgets = $.grep( c.widgets, function( val, index ) { + return $.inArray( val, c.widgets ) === index; + }); + names = c.widgets || []; + len = names.length; + // build widget array & add priority as needed + for ( indx = 0; indx < len; indx++ ) { + widget = ts.getWidgetById( names[ indx ] ); + if ( widget && widget.id ) { + // set priority to 10 if not defined + if ( !widget.priority ) { widget.priority = 10; } + widgets[ indx ] = widget; + } else if ( c.debug ) { + console.warn( '"' + names[ indx ] + '" was enabled, but the widget code has not been loaded!' ); + } + } + // sort widgets by priority + widgets.sort( function( a, b ) { + return a.priority < b.priority ? -1 : a.priority === b.priority ? 0 : 1; + }); + // add/update selected widgets + len = widgets.length; + if ( c.debug ) { + console[ console.group ? 'group' : 'log' ]( 'Start ' + ( init ? 'initializing' : 'applying' ) + ' widgets' ); + } + for ( indx = 0; indx < len; indx++ ) { + widget = widgets[ indx ]; + if ( widget && widget.id ) { + ts.applyWidgetId( table, widget.id, init ); + } + } + if ( c.debug && console.groupEnd ) { console.groupEnd(); } + } + c.timerReady = setTimeout( function() { + table.isApplyingWidgets = false; + $.data( table, 'lastWidgetApplication', new Date() ); + c.$table.triggerHandler( 'tablesorter-ready' ); + // callback executed on init only + if ( !init && typeof callback === 'function' ) { + callback( table ); + } + if ( c.debug ) { + widget = c.widgets.length; + console.log( 'Completed ' + + ( init === true ? 'initializing ' : 'applying ' ) + widget + + ' widget' + ( widget !== 1 ? 's' : '' ) + ts.benchmark( time ) ); + } + }, 10 ); + }, + + removeWidget : function( table, name, refreshing ) { + table = $( table )[ 0 ]; + var index, widget, indx, len, + c = table.config; + // if name === true, add all widgets from $.tablesorter.widgets + if ( name === true ) { + name = []; + len = ts.widgets.length; + for ( indx = 0; indx < len; indx++ ) { + widget = ts.widgets[ indx ]; + if ( widget && widget.id ) { + name[ name.length ] = widget.id; + } + } + } else { + // name can be either an array of widgets names, + // or a space/comma separated list of widget names + name = ( $.isArray( name ) ? name.join( ',' ) : name || '' ).toLowerCase().split( /[\s,]+/ ); + } + len = name.length; + for ( index = 0; index < len; index++ ) { + widget = ts.getWidgetById( name[ index ] ); + indx = $.inArray( name[ index ], c.widgets ); + // don't remove the widget from config.widget if refreshing + if ( indx >= 0 && refreshing !== true ) { + c.widgets.splice( indx, 1 ); + } + if ( widget && widget.remove ) { + if ( c.debug ) { + console.log( ( refreshing ? 'Refreshing' : 'Removing' ) + ' "' + name[ index ] + '" widget' ); + } + widget.remove( table, c, c.widgetOptions, refreshing ); + c.widgetInit[ name[ index ] ] = false; + } + } + c.$table.triggerHandler( 'widgetRemoveEnd', table ); + }, + + refreshWidgets : function( table, doAll, dontapply ) { + table = $( table )[ 0 ]; // see issue #243 + var indx, widget, + c = table.config, + curWidgets = c.widgets, + widgets = ts.widgets, + len = widgets.length, + list = [], + callback = function( table ) { + $( table ).triggerHandler( 'refreshComplete' ); + }; + // remove widgets not defined in config.widgets, unless doAll is true + for ( indx = 0; indx < len; indx++ ) { + widget = widgets[ indx ]; + if ( widget && widget.id && ( doAll || $.inArray( widget.id, curWidgets ) < 0 ) ) { + list[ list.length ] = widget.id; + } + } + ts.removeWidget( table, list.join( ',' ), true ); + if ( dontapply !== true ) { + // call widget init if + ts.applyWidget( table, doAll || false, callback ); + if ( doAll ) { + // apply widget format + ts.applyWidget( table, false, callback ); + } + } else { + callback( table ); + } + }, + + /* + ██ ██ ██████ ██ ██ ██ ██████ ██ ██████ ▄█████ + ██ ██ ██ ██ ██ ██ ██ ██ ██▄▄ ▀█▄ + ██ ██ ██ ██ ██ ██ ██ ██ ██▀▀ ▀█▄ + ▀████▀ ██ ██ ██████ ██ ██ ██ ██████ █████▀ + */ + benchmark : function( diff ) { + return ( ' (' + ( new Date().getTime() - diff.getTime() ) + ' ms)' ); + }, + // deprecated ts.log + log : function() { + console.log( arguments ); + }, + + // $.isEmptyObject from jQuery v1.4 + isEmptyObject : function( obj ) { + /*jshint forin: false */ + for ( var name in obj ) { + return false; + } + return true; + }, + + isValueInArray : function( column, arry ) { + var indx, + len = arry && arry.length || 0; + for ( indx = 0; indx < len; indx++ ) { + if ( arry[ indx ][ 0 ] === column ) { + return indx; + } + } + return -1; + }, + + formatFloat : function( str, table ) { + if ( typeof str !== 'string' || str === '' ) { return str; } + // allow using formatFloat without a table; defaults to US number format + var num, + usFormat = table && table.config ? table.config.usNumberFormat !== false : + typeof table !== 'undefined' ? table : true; + if ( usFormat ) { + // US Format - 1,234,567.89 -> 1234567.89 + str = str.replace( ts.regex.comma, '' ); + } else { + // German Format = 1.234.567,89 -> 1234567.89 + // French Format = 1 234 567,89 -> 1234567.89 + str = str.replace( ts.regex.digitNonUS, '' ).replace( ts.regex.comma, '.' ); + } + if ( ts.regex.digitNegativeTest.test( str ) ) { + // make (#) into a negative number -> (10) = -10 + str = str.replace( ts.regex.digitNegativeReplace, '-$1' ); + } + num = parseFloat( str ); + // return the text instead of zero + return isNaN( num ) ? $.trim( str ) : num; + }, + + isDigit : function( str ) { + // replace all unwanted chars and match + return isNaN( str ) ? + ts.regex.digitTest.test( str.toString().replace( ts.regex.digitReplace, '' ) ) : + str !== ''; + }, + + // computeTableHeaderCellIndexes from: + // http://www.javascripttoolbox.com/lib/table/examples.php + // http://www.javascripttoolbox.com/temp/table_cellindex.html + computeColumnIndex : function( $rows, c ) { + var i, j, k, l, cell, cells, rowIndex, rowSpan, colSpan, firstAvailCol, + // total columns has been calculated, use it to set the matrixrow + columns = c && c.columns || 0, + matrix = [], + matrixrow = new Array( columns ); + for ( i = 0; i < $rows.length; i++ ) { + cells = $rows[ i ].cells; + for ( j = 0; j < cells.length; j++ ) { + cell = cells[ j ]; + rowIndex = i; + rowSpan = cell.rowSpan || 1; + colSpan = cell.colSpan || 1; + if ( typeof matrix[ rowIndex ] === 'undefined' ) { + matrix[ rowIndex ] = []; + } + // Find first available column in the first row + for ( k = 0; k < matrix[ rowIndex ].length + 1; k++ ) { + if ( typeof matrix[ rowIndex ][ k ] === 'undefined' ) { + firstAvailCol = k; + break; + } + } + // jscs:disable disallowEmptyBlocks + if ( columns && cell.cellIndex === firstAvailCol ) { + // don't to anything + } else if ( cell.setAttribute ) { + // jscs:enable disallowEmptyBlocks + // add data-column (setAttribute = IE8+) + cell.setAttribute( 'data-column', firstAvailCol ); + } else { + // remove once we drop support for IE7 - 1/12/2016 + $( cell ).attr( 'data-column', firstAvailCol ); + } + for ( k = rowIndex; k < rowIndex + rowSpan; k++ ) { + if ( typeof matrix[ k ] === 'undefined' ) { + matrix[ k ] = []; + } + matrixrow = matrix[ k ]; + for ( l = firstAvailCol; l < firstAvailCol + colSpan; l++ ) { + matrixrow[ l ] = 'x'; + } + } + } + } + ts.checkColumnCount($rows, matrix, matrixrow.length); + return matrixrow.length; + }, + + checkColumnCount : function($rows, matrix, columns) { + // this DOES NOT report any tbody column issues, except for the math and + // and column selector widgets + var i, len, + valid = true, + cells = []; + for ( i = 0; i < matrix.length; i++ ) { + // some matrix entries are undefined when testing the footer because + // it is using the rowIndex property + if ( matrix[i] ) { + len = matrix[i].length; + if ( matrix[i].length !== columns ) { + valid = false; + break; + } + } + } + if ( !valid ) { + $rows.each( function( indx, el ) { + var cell = el.parentElement.nodeName; + if ( cells.indexOf( cell ) < 0 ) { + cells.push( cell ); + } + }); + console.error( + 'Invalid or incorrect number of columns in the ' + + cells.join( ' or ' ) + '; expected ' + columns + + ', but found ' + len + ' columns' + ); + } + }, + + // automatically add a colgroup with col elements set to a percentage width + fixColumnWidth : function( table ) { + table = $( table )[ 0 ]; + var overallWidth, percent, $tbodies, len, index, + c = table.config, + $colgroup = c.$table.children( 'colgroup' ); + // remove plugin-added colgroup, in case we need to refresh the widths + if ( $colgroup.length && $colgroup.hasClass( ts.css.colgroup ) ) { + $colgroup.remove(); + } + if ( c.widthFixed && c.$table.children( 'colgroup' ).length === 0 ) { + $colgroup = $( '' ); + overallWidth = c.$table.width(); + // only add col for visible columns - fixes #371 + $tbodies = c.$tbodies.find( 'tr:first' ).children( ':visible' ); + len = $tbodies.length; + for ( index = 0; index < len; index++ ) { + percent = parseInt( ( $tbodies.eq( index ).width() / overallWidth ) * 1000, 10 ) / 10 + '%'; + $colgroup.append( $( '' ).css( 'width', percent ) ); + } + c.$table.prepend( $colgroup ); + } + }, + + // get sorter, string, empty, etc options for each column from + // jQuery data, metadata, header option or header class name ('sorter-false') + // priority = jQuery data > meta > headers option > header class name + getData : function( header, configHeader, key ) { + var meta, cl4ss, + val = '', + $header = $( header ); + if ( !$header.length ) { return ''; } + meta = $.metadata ? $header.metadata() : false; + cl4ss = ' ' + ( $header.attr( 'class' ) || '' ); + if ( typeof $header.data( key ) !== 'undefined' || + typeof $header.data( key.toLowerCase() ) !== 'undefined' ) { + // 'data-lockedOrder' is assigned to 'lockedorder'; but 'data-locked-order' is assigned to 'lockedOrder' + // 'data-sort-initial-order' is assigned to 'sortInitialOrder' + val += $header.data( key ) || $header.data( key.toLowerCase() ); + } else if ( meta && typeof meta[ key ] !== 'undefined' ) { + val += meta[ key ]; + } else if ( configHeader && typeof configHeader[ key ] !== 'undefined' ) { + val += configHeader[ key ]; + } else if ( cl4ss !== ' ' && cl4ss.match( ' ' + key + '-' ) ) { + // include sorter class name 'sorter-text', etc; now works with 'sorter-my-custom-parser' + val = cl4ss.match( new RegExp( '\\s' + key + '-([\\w-]+)' ) )[ 1 ] || ''; + } + return $.trim( val ); + }, + + getColumnData : function( table, obj, indx, getCell, $headers ) { + if ( typeof obj !== 'object' || obj === null ) { + return obj; + } + table = $( table )[ 0 ]; + var $header, key, + c = table.config, + $cells = ( $headers || c.$headers ), + // c.$headerIndexed is not defined initially + $cell = c.$headerIndexed && c.$headerIndexed[ indx ] || + $cells.filter( '[data-column="' + indx + '"]:last' ); + if ( typeof obj[ indx ] !== 'undefined' ) { + return getCell ? obj[ indx ] : obj[ $cells.index( $cell ) ]; + } + for ( key in obj ) { + if ( typeof key === 'string' ) { + $header = $cell + // header cell with class/id + .filter( key ) + // find elements within the header cell with cell/id + .add( $cell.find( key ) ); + if ( $header.length ) { + return obj[ key ]; + } + } + } + return; + }, + + // *** Process table *** + // add processing indicator + isProcessing : function( $table, toggle, $headers ) { + $table = $( $table ); + var c = $table[ 0 ].config, + // default to all headers + $header = $headers || $table.find( '.' + ts.css.header ); + if ( toggle ) { + // don't use sortList if custom $headers used + if ( typeof $headers !== 'undefined' && c.sortList.length > 0 ) { + // get headers from the sortList + $header = $header.filter( function() { + // get data-column from attr to keep compatibility with jQuery 1.2.6 + return this.sortDisabled ? + false : + ts.isValueInArray( parseFloat( $( this ).attr( 'data-column' ) ), c.sortList ) >= 0; + }); + } + $table.add( $header ).addClass( ts.css.processing + ' ' + c.cssProcessing ); + } else { + $table.add( $header ).removeClass( ts.css.processing + ' ' + c.cssProcessing ); + } + }, + + // detach tbody but save the position + // don't use tbody because there are portions that look for a tbody index (updateCell) + processTbody : function( table, $tb, getIt ) { + table = $( table )[ 0 ]; + if ( getIt ) { + table.isProcessing = true; + $tb.before( '' ); + return $.fn.detach ? $tb.detach() : $tb.remove(); + } + var holdr = $( table ).find( 'colgroup.tablesorter-savemyplace' ); + $tb.insertAfter( holdr ); + holdr.remove(); + table.isProcessing = false; + }, + + clearTableBody : function( table ) { + $( table )[ 0 ].config.$tbodies.children().detach(); + }, + + // used when replacing accented characters during sorting + characterEquivalents : { + 'a' : '\u00e1\u00e0\u00e2\u00e3\u00e4\u0105\u00e5', // áàâãäąå + 'A' : '\u00c1\u00c0\u00c2\u00c3\u00c4\u0104\u00c5', // ÁÀÂÃÄĄÅ + 'c' : '\u00e7\u0107\u010d', // çćč + 'C' : '\u00c7\u0106\u010c', // ÇĆČ + 'e' : '\u00e9\u00e8\u00ea\u00eb\u011b\u0119', // éèêëěę + 'E' : '\u00c9\u00c8\u00ca\u00cb\u011a\u0118', // ÉÈÊËĚĘ + 'i' : '\u00ed\u00ec\u0130\u00ee\u00ef\u0131', // íìİîïı + 'I' : '\u00cd\u00cc\u0130\u00ce\u00cf', // ÍÌİÎÏ + 'o' : '\u00f3\u00f2\u00f4\u00f5\u00f6\u014d', // óòôõöō + 'O' : '\u00d3\u00d2\u00d4\u00d5\u00d6\u014c', // ÓÒÔÕÖŌ + 'ss': '\u00df', // ß (s sharp) + 'SS': '\u1e9e', // ẞ (Capital sharp s) + 'u' : '\u00fa\u00f9\u00fb\u00fc\u016f', // úùûüů + 'U' : '\u00da\u00d9\u00db\u00dc\u016e' // ÚÙÛÜŮ + }, + + replaceAccents : function( str ) { + var chr, + acc = '[', + eq = ts.characterEquivalents; + if ( !ts.characterRegex ) { + ts.characterRegexArray = {}; + for ( chr in eq ) { + if ( typeof chr === 'string' ) { + acc += eq[ chr ]; + ts.characterRegexArray[ chr ] = new RegExp( '[' + eq[ chr ] + ']', 'g' ); + } + } + ts.characterRegex = new RegExp( acc + ']' ); + } + if ( ts.characterRegex.test( str ) ) { + for ( chr in eq ) { + if ( typeof chr === 'string' ) { + str = str.replace( ts.characterRegexArray[ chr ], chr ); + } + } + } + return str; + }, + + validateOptions : function( c ) { + var setting, setting2, typ, timer, + // ignore options containing an array + ignore = 'headers sortForce sortList sortAppend widgets'.split( ' ' ), + orig = c.originalSettings; + if ( orig ) { + if ( c.debug ) { + timer = new Date(); + } + for ( setting in orig ) { + typ = typeof ts.defaults[setting]; + if ( typ === 'undefined' ) { + console.warn( 'Tablesorter Warning! "table.config.' + setting + '" option not recognized' ); + } else if ( typ === 'object' ) { + for ( setting2 in orig[setting] ) { + typ = ts.defaults[setting] && typeof ts.defaults[setting][setting2]; + if ( $.inArray( setting, ignore ) < 0 && typ === 'undefined' ) { + console.warn( 'Tablesorter Warning! "table.config.' + setting + '.' + setting2 + '" option not recognized' ); + } + } + } + } + if ( c.debug ) { + console.log( 'validate options time:' + ts.benchmark( timer ) ); + } + } + }, + + // restore headers + restoreHeaders : function( table ) { + var index, $cell, + c = $( table )[ 0 ].config, + $headers = c.$table.find( c.selectorHeaders ), + len = $headers.length; + // don't use c.$headers here in case header cells were swapped + for ( index = 0; index < len; index++ ) { + $cell = $headers.eq( index ); + // only restore header cells if it is wrapped + // because this is also used by the updateAll method + if ( $cell.find( '.' + ts.css.headerIn ).length ) { + $cell.html( c.headerContent[ index ] ); + } + } + }, + + destroy : function( table, removeClasses, callback ) { + table = $( table )[ 0 ]; + if ( !table.hasInitialized ) { return; } + // remove all widgets + ts.removeWidget( table, true, false ); + var events, + $t = $( table ), + c = table.config, + debug = c.debug, + $h = $t.find( 'thead:first' ), + $r = $h.find( 'tr.' + ts.css.headerRow ).removeClass( ts.css.headerRow + ' ' + c.cssHeaderRow ), + $f = $t.find( 'tfoot:first > tr' ).children( 'th, td' ); + if ( removeClasses === false && $.inArray( 'uitheme', c.widgets ) >= 0 ) { + // reapply uitheme classes, in case we want to maintain appearance + $t.triggerHandler( 'applyWidgetId', [ 'uitheme' ] ); + $t.triggerHandler( 'applyWidgetId', [ 'zebra' ] ); + } + // remove widget added rows, just in case + $h.find( 'tr' ).not( $r ).remove(); + // disable tablesorter - not using .unbind( namespace ) because namespacing was + // added in jQuery v1.4.3 - see http://api.jquery.com/event.namespace/ + events = 'sortReset update updateRows updateAll updateHeaders updateCell addRows updateComplete sorton ' + + 'appendCache updateCache applyWidgetId applyWidgets refreshWidgets removeWidget destroy mouseup mouseleave ' + + 'keypress sortBegin sortEnd resetToLoadState '.split( ' ' ) + .join( c.namespace + ' ' ); + $t + .removeData( 'tablesorter' ) + .unbind( events.replace( ts.regex.spaces, ' ' ) ); + c.$headers + .add( $f ) + .removeClass( [ ts.css.header, c.cssHeader, c.cssAsc, c.cssDesc, ts.css.sortAsc, ts.css.sortDesc, ts.css.sortNone ].join( ' ' ) ) + .removeAttr( 'data-column' ) + .removeAttr( 'aria-label' ) + .attr( 'aria-disabled', 'true' ); + $r + .find( c.selectorSort ) + .unbind( ( 'mousedown mouseup keypress '.split( ' ' ).join( c.namespace + ' ' ) ).replace( ts.regex.spaces, ' ' ) ); + ts.restoreHeaders( table ); + $t.toggleClass( ts.css.table + ' ' + c.tableClass + ' tablesorter-' + c.theme, removeClasses === false ); + $t.removeClass(c.namespace.slice(1)); + // clear flag in case the plugin is initialized again + table.hasInitialized = false; + delete table.config.cache; + if ( typeof callback === 'function' ) { + callback( table ); + } + if ( debug ) { + console.log( 'tablesorter has been removed' ); + } + } + + }; + + $.fn.tablesorter = function( settings ) { + return this.each( function() { + var table = this, + // merge & extend config options + c = $.extend( true, {}, ts.defaults, settings, ts.instanceMethods ); + // save initial settings + c.originalSettings = settings; + // create a table from data (build table widget) + if ( !table.hasInitialized && ts.buildTable && this.nodeName !== 'TABLE' ) { + // return the table (in case the original target is the table's container) + ts.buildTable( table, c ); + } else { + ts.setup( table, c ); + } + }); + }; + + // set up debug logs + if ( !( window.console && window.console.log ) ) { + // access $.tablesorter.logs for browsers that don't have a console... + ts.logs = []; + /*jshint -W020 */ + console = {}; + console.log = console.warn = console.error = console.table = function() { + var arg = arguments.length > 1 ? arguments : arguments[0]; + ts.logs[ ts.logs.length ] = { date: Date.now(), log: arg }; + }; + } + + // add default parsers + ts.addParser({ + id : 'no-parser', + is : function() { + return false; + }, + format : function() { + return ''; + }, + type : 'text' + }); + + ts.addParser({ + id : 'text', + is : function() { + return true; + }, + format : function( str, table ) { + var c = table.config; + if ( str ) { + str = $.trim( c.ignoreCase ? str.toLocaleLowerCase() : str ); + str = c.sortLocaleCompare ? ts.replaceAccents( str ) : str; + } + return str; + }, + type : 'text' + }); + + ts.regex.nondigit = /[^\w,. \-()]/g; + ts.addParser({ + id : 'digit', + is : function( str ) { + return ts.isDigit( str ); + }, + format : function( str, table ) { + var num = ts.formatFloat( ( str || '' ).replace( ts.regex.nondigit, '' ), table ); + return str && typeof num === 'number' ? num : + str ? $.trim( str && table.config.ignoreCase ? str.toLocaleLowerCase() : str ) : str; + }, + type : 'numeric' + }); + + ts.regex.currencyReplace = /[+\-,. ]/g; + ts.regex.currencyTest = /^\(?\d+[\u00a3$\u20ac\u00a4\u00a5\u00a2?.]|[\u00a3$\u20ac\u00a4\u00a5\u00a2?.]\d+\)?$/; + ts.addParser({ + id : 'currency', + is : function( str ) { + str = ( str || '' ).replace( ts.regex.currencyReplace, '' ); + // test for £$€¤¥¢ + return ts.regex.currencyTest.test( str ); + }, + format : function( str, table ) { + var num = ts.formatFloat( ( str || '' ).replace( ts.regex.nondigit, '' ), table ); + return str && typeof num === 'number' ? num : + str ? $.trim( str && table.config.ignoreCase ? str.toLocaleLowerCase() : str ) : str; + }, + type : 'numeric' + }); + + // too many protocols to add them all https://en.wikipedia.org/wiki/URI_scheme + // now, this regex can be updated before initialization + ts.regex.urlProtocolTest = /^(https?|ftp|file):\/\//; + ts.regex.urlProtocolReplace = /(https?|ftp|file):\/\/(www\.)?/; + ts.addParser({ + id : 'url', + is : function( str ) { + return ts.regex.urlProtocolTest.test( str ); + }, + format : function( str ) { + return str ? $.trim( str.replace( ts.regex.urlProtocolReplace, '' ) ) : str; + }, + type : 'text' + }); + + ts.regex.dash = /-/g; + ts.regex.isoDate = /^\d{4}[\/\-]\d{1,2}[\/\-]\d{1,2}/; + ts.addParser({ + id : 'isoDate', + is : function( str ) { + return ts.regex.isoDate.test( str ); + }, + format : function( str, table ) { + var date = str ? new Date( str.replace( ts.regex.dash, '/' ) ) : str; + return date instanceof Date && isFinite( date ) ? date.getTime() : str; + }, + type : 'numeric' + }); + + ts.regex.percent = /%/g; + ts.regex.percentTest = /(\d\s*?%|%\s*?\d)/; + ts.addParser({ + id : 'percent', + is : function( str ) { + return ts.regex.percentTest.test( str ) && str.length < 15; + }, + format : function( str, table ) { + return str ? ts.formatFloat( str.replace( ts.regex.percent, '' ), table ) : str; + }, + type : 'numeric' + }); + + // added image parser to core v2.17.9 + ts.addParser({ + id : 'image', + is : function( str, table, node, $node ) { + return $node.find( 'img' ).length > 0; + }, + format : function( str, table, cell ) { + return $( cell ).find( 'img' ).attr( table.config.imgAttr || 'alt' ) || str; + }, + parsed : true, // filter widget flag + type : 'text' + }); + + ts.regex.dateReplace = /(\S)([AP]M)$/i; // used by usLongDate & time parser + ts.regex.usLongDateTest1 = /^[A-Z]{3,10}\.?\s+\d{1,2},?\s+(\d{4})(\s+\d{1,2}:\d{2}(:\d{2})?(\s+[AP]M)?)?$/i; + ts.regex.usLongDateTest2 = /^\d{1,2}\s+[A-Z]{3,10}\s+\d{4}/i; + ts.addParser({ + id : 'usLongDate', + is : function( str ) { + // two digit years are not allowed cross-browser + // Jan 01, 2013 12:34:56 PM or 01 Jan 2013 + return ts.regex.usLongDateTest1.test( str ) || ts.regex.usLongDateTest2.test( str ); + }, + format : function( str, table ) { + var date = str ? new Date( str.replace( ts.regex.dateReplace, '$1 $2' ) ) : str; + return date instanceof Date && isFinite( date ) ? date.getTime() : str; + }, + type : 'numeric' + }); + + // testing for ##-##-#### or ####-##-##, so it's not perfect; time can be included + ts.regex.shortDateTest = /(^\d{1,2}[\/\s]\d{1,2}[\/\s]\d{4})|(^\d{4}[\/\s]\d{1,2}[\/\s]\d{1,2})/; + // escaped "-" because JSHint in Firefox was showing it as an error + ts.regex.shortDateReplace = /[\-.,]/g; + // XXY covers MDY & DMY formats + ts.regex.shortDateXXY = /(\d{1,2})[\/\s](\d{1,2})[\/\s](\d{4})/; + ts.regex.shortDateYMD = /(\d{4})[\/\s](\d{1,2})[\/\s](\d{1,2})/; + ts.convertFormat = function( dateString, format ) { + dateString = ( dateString || '' ) + .replace( ts.regex.spaces, ' ' ) + .replace( ts.regex.shortDateReplace, '/' ); + if ( format === 'mmddyyyy' ) { + dateString = dateString.replace( ts.regex.shortDateXXY, '$3/$1/$2' ); + } else if ( format === 'ddmmyyyy' ) { + dateString = dateString.replace( ts.regex.shortDateXXY, '$3/$2/$1' ); + } else if ( format === 'yyyymmdd' ) { + dateString = dateString.replace( ts.regex.shortDateYMD, '$1/$2/$3' ); + } + var date = new Date( dateString ); + return date instanceof Date && isFinite( date ) ? date.getTime() : ''; + }; + + ts.addParser({ + id : 'shortDate', // 'mmddyyyy', 'ddmmyyyy' or 'yyyymmdd' + is : function( str ) { + str = ( str || '' ).replace( ts.regex.spaces, ' ' ).replace( ts.regex.shortDateReplace, '/' ); + return ts.regex.shortDateTest.test( str ); + }, + format : function( str, table, cell, cellIndex ) { + if ( str ) { + var c = table.config, + $header = c.$headerIndexed[ cellIndex ], + format = $header.length && $header.data( 'dateFormat' ) || + ts.getData( $header, ts.getColumnData( table, c.headers, cellIndex ), 'dateFormat' ) || + c.dateFormat; + // save format because getData can be slow... + if ( $header.length ) { + $header.data( 'dateFormat', format ); + } + return ts.convertFormat( str, format ) || str; + } + return str; + }, + type : 'numeric' + }); + + // match 24 hour time & 12 hours time + am/pm - see http://regexr.com/3c3tk + ts.regex.timeTest = /^(0?[1-9]|1[0-2]):([0-5]\d)(\s[AP]M)$|^((?:[01]\d|[2][0-4]):[0-5]\d)$/i; + ts.regex.timeMatch = /(0?[1-9]|1[0-2]):([0-5]\d)(\s[AP]M)|((?:[01]\d|[2][0-4]):[0-5]\d)/i; + ts.addParser({ + id : 'time', + is : function( str ) { + return ts.regex.timeTest.test( str ); + }, + format : function( str, table ) { + // isolate time... ignore month, day and year + var temp, + timePart = ( str || '' ).match( ts.regex.timeMatch ), + orig = new Date( str ), + // no time component? default to 00:00 by leaving it out, but only if str is defined + time = str && ( timePart !== null ? timePart[ 0 ] : '00:00 AM' ), + date = time ? new Date( '2000/01/01 ' + time.replace( ts.regex.dateReplace, '$1 $2' ) ) : time; + if ( date instanceof Date && isFinite( date ) ) { + temp = orig instanceof Date && isFinite( orig ) ? orig.getTime() : 0; + // if original string was a valid date, add it to the decimal so the column sorts in some kind of order + // luckily new Date() ignores the decimals + return temp ? parseFloat( date.getTime() + '.' + orig.getTime() ) : date.getTime(); + } + return str; + }, + type : 'numeric' + }); + + ts.addParser({ + id : 'metadata', + is : function() { + return false; + }, + format : function( str, table, cell ) { + var c = table.config, + p = ( !c.parserMetadataName ) ? 'sortValue' : c.parserMetadataName; + return $( cell ).metadata()[ p ]; + }, + type : 'numeric' + }); + + /* + ██████ ██████ █████▄ █████▄ ▄████▄ + ▄█▀ ██▄▄ ██▄▄██ ██▄▄██ ██▄▄██ + ▄█▀ ██▀▀ ██▀▀██ ██▀▀█ ██▀▀██ + ██████ ██████ █████▀ ██ ██ ██ ██ + */ + // add default widgets + ts.addWidget({ + id : 'zebra', + priority : 90, + format : function( table, c, wo ) { + var $visibleRows, $row, count, isEven, tbodyIndex, rowIndex, len, + child = new RegExp( c.cssChildRow, 'i' ), + $tbodies = c.$tbodies.add( $( c.namespace + '_extra_table' ).children( 'tbody:not(.' + c.cssInfoBlock + ')' ) ); + for ( tbodyIndex = 0; tbodyIndex < $tbodies.length; tbodyIndex++ ) { + // loop through the visible rows + count = 0; + $visibleRows = $tbodies.eq( tbodyIndex ).children( 'tr:visible' ).not( c.selectorRemove ); + len = $visibleRows.length; + for ( rowIndex = 0; rowIndex < len; rowIndex++ ) { + $row = $visibleRows.eq( rowIndex ); + // style child rows the same way the parent row was styled + if ( !child.test( $row[ 0 ].className ) ) { count++; } + isEven = ( count % 2 === 0 ); + $row + .removeClass( wo.zebra[ isEven ? 1 : 0 ] ) + .addClass( wo.zebra[ isEven ? 0 : 1 ] ); + } + } + }, + remove : function( table, c, wo, refreshing ) { + if ( refreshing ) { return; } + var tbodyIndex, $tbody, + $tbodies = c.$tbodies, + toRemove = ( wo.zebra || [ 'even', 'odd' ] ).join( ' ' ); + for ( tbodyIndex = 0; tbodyIndex < $tbodies.length; tbodyIndex++ ){ + $tbody = ts.processTbody( table, $tbodies.eq( tbodyIndex ), true ); // remove tbody + $tbody.children().removeClass( toRemove ); + ts.processTbody( table, $tbody, false ); // restore tbody + } + } + }); + +})( jQuery ); + +/*! Widget: storage - updated 4/18/2017 (v2.28.8) */ +/*global JSON:false */ +;(function ($, window, document) { + 'use strict'; + + var ts = $.tablesorter || {}; + + // update defaults for validator; these values must be falsy! + $.extend(true, ts.defaults, { + fixedUrl: '', + widgetOptions: { + storage_fixedUrl: '', + storage_group: '', + storage_page: '', + storage_storageType: '', + storage_tableId: '', + storage_useSessionStorage: '' + } + }); + + // *** Store data in local storage, with a cookie fallback *** + /* IE7 needs JSON library for JSON.stringify - (http://caniuse.com/#search=json) + if you need it, then include https://github.com/douglascrockford/JSON-js + + $.parseJSON is not available is jQuery versions older than 1.4.1, using older + versions will only allow storing information for one page at a time + + // *** Save data (JSON format only) *** + // val must be valid JSON... use http://jsonlint.com/ to ensure it is valid + var val = { "mywidget" : "data1" }; // valid JSON uses double quotes + // $.tablesorter.storage(table, key, val); + $.tablesorter.storage(table, 'tablesorter-mywidget', val); + + // *** Get data: $.tablesorter.storage(table, key); *** + v = $.tablesorter.storage(table, 'tablesorter-mywidget'); + // val may be empty, so also check for your data + val = (v && v.hasOwnProperty('mywidget')) ? v.mywidget : ''; + alert(val); // 'data1' if saved, or '' if not + */ + ts.storage = function(table, key, value, options) { + table = $(table)[0]; + var cookieIndex, cookies, date, + hasStorage = false, + values = {}, + c = table.config, + wo = c && c.widgetOptions, + storageType = ( + ( options && options.storageType ) || ( wo && wo.storage_storageType ) + ).toString().charAt(0).toLowerCase(), + // deprecating "useSessionStorage"; any storageType setting overrides it + session = storageType ? '' : + ( options && options.useSessionStorage ) || ( wo && wo.storage_useSessionStorage ), + $table = $(table), + // id from (1) options ID, (2) table 'data-table-group' attribute, (3) widgetOptions.storage_tableId, + // (4) table ID, then (5) table index + id = options && options.id || + $table.attr( options && options.group || wo && wo.storage_group || 'data-table-group') || + wo && wo.storage_tableId || table.id || $('.tablesorter').index( $table ), + // url from (1) options url, (2) table 'data-table-page' attribute, (3) widgetOptions.storage_fixedUrl, + // (4) table.config.fixedUrl (deprecated), then (5) window location path + url = options && options.url || + $table.attr(options && options.page || wo && wo.storage_page || 'data-table-page') || + wo && wo.storage_fixedUrl || c && c.fixedUrl || window.location.pathname; + + // skip if using cookies + if (storageType !== 'c') { + storageType = (storageType === 's' || session) ? 'sessionStorage' : 'localStorage'; + // https://gist.github.com/paulirish/5558557 + if (storageType in window) { + try { + window[storageType].setItem('_tmptest', 'temp'); + hasStorage = true; + window[storageType].removeItem('_tmptest'); + } catch (error) { + if (c && c.debug) { + console.warn( storageType + ' is not supported in this browser' ); + } + } + } + } + if (c.debug) { + console.log('Storage widget using', hasStorage ? storageType : 'cookies'); + } + // *** get value *** + if ($.parseJSON) { + if (hasStorage) { + values = $.parseJSON( window[storageType][key] || 'null' ) || {}; + } else { + // old browser, using cookies + cookies = document.cookie.split(/[;\s|=]/); + // add one to get from the key to the value + cookieIndex = $.inArray(key, cookies) + 1; + values = (cookieIndex !== 0) ? $.parseJSON(cookies[cookieIndex] || 'null') || {} : {}; + } + } + // allow value to be an empty string too + if (typeof value !== 'undefined' && window.JSON && JSON.hasOwnProperty('stringify')) { + // add unique identifiers = url pathname > table ID/index on page > data + if (!values[url]) { + values[url] = {}; + } + values[url][id] = value; + // *** set value *** + if (hasStorage) { + window[storageType][key] = JSON.stringify(values); + } else { + date = new Date(); + date.setTime(date.getTime() + (31536e+6)); // 365 days + document.cookie = key + '=' + (JSON.stringify(values)).replace(/\"/g, '\"') + '; expires=' + date.toGMTString() + '; path=/'; + } + } else { + return values && values[url] ? values[url][id] : ''; + } + }; + +})(jQuery, window, document); + +/*! Widget: uitheme - updated 9/27/2017 (v2.29.0) */ +;(function ($) { + 'use strict'; + var ts = $.tablesorter || {}; + + ts.themes = { + 'bootstrap' : { + table : 'table table-bordered table-striped', + caption : 'caption', + // header class names + header : 'bootstrap-header', // give the header a gradient background (theme.bootstrap_2.css) + sortNone : '', + sortAsc : '', + sortDesc : '', + active : '', // applied when column is sorted + hover : '', // custom css required - a defined bootstrap style may not override other classes + // icon class names + icons : '', // add 'bootstrap-icon-white' to make them white; this icon class is added to the in the header + iconSortNone : 'bootstrap-icon-unsorted', // class name added to icon when column is not sorted + iconSortAsc : 'glyphicon glyphicon-chevron-up', // class name added to icon when column has ascending sort + iconSortDesc : 'glyphicon glyphicon-chevron-down', // class name added to icon when column has descending sort + filterRow : '', // filter row class + footerRow : '', + footerCells : '', + even : '', // even row zebra striping + odd : '' // odd row zebra striping + }, + 'jui' : { + table : 'ui-widget ui-widget-content ui-corner-all', // table classes + caption : 'ui-widget-content', + // header class names + header : 'ui-widget-header ui-corner-all ui-state-default', // header classes + sortNone : '', + sortAsc : '', + sortDesc : '', + active : 'ui-state-active', // applied when column is sorted + hover : 'ui-state-hover', // hover class + // icon class names + icons : 'ui-icon', // icon class added to the in the header + iconSortNone : 'ui-icon-carat-2-n-s ui-icon-caret-2-n-s', // class name added to icon when column is not sorted + iconSortAsc : 'ui-icon-carat-1-n ui-icon-caret-1-n', // class name added to icon when column has ascending sort + iconSortDesc : 'ui-icon-carat-1-s ui-icon-caret-1-s', // class name added to icon when column has descending sort + filterRow : '', + footerRow : '', + footerCells : '', + even : 'ui-widget-content', // even row zebra striping + odd : 'ui-state-default' // odd row zebra striping + } + }; + + $.extend(ts.css, { + wrapper : 'tablesorter-wrapper' // ui theme & resizable + }); + + ts.addWidget({ + id: 'uitheme', + priority: 10, + format: function(table, c, wo) { + var i, tmp, hdr, icon, time, $header, $icon, $tfoot, $h, oldtheme, oldremove, oldIconRmv, hasOldTheme, + themesAll = ts.themes, + $table = c.$table.add( $( c.namespace + '_extra_table' ) ), + $headers = c.$headers.add( $( c.namespace + '_extra_headers' ) ), + theme = c.theme || 'jui', + themes = themesAll[theme] || {}, + remove = $.trim( [ themes.sortNone, themes.sortDesc, themes.sortAsc, themes.active ].join( ' ' ) ), + iconRmv = $.trim( [ themes.iconSortNone, themes.iconSortDesc, themes.iconSortAsc ].join( ' ' ) ); + if (c.debug) { time = new Date(); } + // initialization code - run once + if (!$table.hasClass('tablesorter-' + theme) || c.theme !== c.appliedTheme || !wo.uitheme_applied) { + wo.uitheme_applied = true; + oldtheme = themesAll[c.appliedTheme] || {}; + hasOldTheme = !$.isEmptyObject(oldtheme); + oldremove = hasOldTheme ? [ oldtheme.sortNone, oldtheme.sortDesc, oldtheme.sortAsc, oldtheme.active ].join( ' ' ) : ''; + oldIconRmv = hasOldTheme ? [ oldtheme.iconSortNone, oldtheme.iconSortDesc, oldtheme.iconSortAsc ].join( ' ' ) : ''; + if (hasOldTheme) { + wo.zebra[0] = $.trim( ' ' + wo.zebra[0].replace(' ' + oldtheme.even, '') ); + wo.zebra[1] = $.trim( ' ' + wo.zebra[1].replace(' ' + oldtheme.odd, '') ); + c.$tbodies.children().removeClass( [ oldtheme.even, oldtheme.odd ].join(' ') ); + } + // update zebra stripes + if (themes.even) { wo.zebra[0] += ' ' + themes.even; } + if (themes.odd) { wo.zebra[1] += ' ' + themes.odd; } + // add caption style + $table.children('caption') + .removeClass(oldtheme.caption || '') + .addClass(themes.caption); + // add table/footer class names + $tfoot = $table + // remove other selected themes + .removeClass( (c.appliedTheme ? 'tablesorter-' + (c.appliedTheme || '') : '') + ' ' + (oldtheme.table || '') ) + .addClass('tablesorter-' + theme + ' ' + (themes.table || '')) // add theme widget class name + .children('tfoot'); + c.appliedTheme = c.theme; + + if ($tfoot.length) { + $tfoot + // if oldtheme.footerRow or oldtheme.footerCells are undefined, all class names are removed + .children('tr').removeClass(oldtheme.footerRow || '').addClass(themes.footerRow) + .children('th, td').removeClass(oldtheme.footerCells || '').addClass(themes.footerCells); + } + // update header classes + $headers + .removeClass( (hasOldTheme ? [ oldtheme.header, oldtheme.hover, oldremove ].join(' ') : '') || '' ) + .addClass(themes.header) + .not('.sorter-false') + .unbind('mouseenter.tsuitheme mouseleave.tsuitheme') + .bind('mouseenter.tsuitheme mouseleave.tsuitheme', function(event) { + // toggleClass with switch added in jQuery 1.3 + $(this)[ event.type === 'mouseenter' ? 'addClass' : 'removeClass' ](themes.hover || ''); + }); + + $headers.each(function(){ + var $this = $(this); + if (!$this.find('.' + ts.css.wrapper).length) { + // Firefox needs this inner div to position the icon & resizer correctly + $this.wrapInner('
    '); + } + }); + if (c.cssIcon) { + // if c.cssIcon is '', then no is added to the header + $headers + .find('.' + ts.css.icon) + .removeClass(hasOldTheme ? [ oldtheme.icons, oldIconRmv ].join(' ') : '') + .addClass(themes.icons || ''); + } + // filter widget initializes after uitheme + if (ts.hasWidget( c.table, 'filter' )) { + tmp = function() { + $table.children('thead').children('.' + ts.css.filterRow) + .removeClass(hasOldTheme ? oldtheme.filterRow || '' : '') + .addClass(themes.filterRow || ''); + }; + if (wo.filter_initialized) { + tmp(); + } else { + $table.one('filterInit', function() { + tmp(); + }); + } + } + } + for (i = 0; i < c.columns; i++) { + $header = c.$headers + .add($(c.namespace + '_extra_headers')) + .not('.sorter-false') + .filter('[data-column="' + i + '"]'); + $icon = (ts.css.icon) ? $header.find('.' + ts.css.icon) : $(); + $h = $headers.not('.sorter-false').filter('[data-column="' + i + '"]:last'); + if ($h.length) { + $header.removeClass(remove); + $icon.removeClass(iconRmv); + if ($h[0].sortDisabled) { + // no sort arrows for disabled columns! + $icon.removeClass(themes.icons || ''); + } else { + hdr = themes.sortNone; + icon = themes.iconSortNone; + if ($h.hasClass(ts.css.sortAsc)) { + hdr = [ themes.sortAsc, themes.active ].join(' '); + icon = themes.iconSortAsc; + } else if ($h.hasClass(ts.css.sortDesc)) { + hdr = [ themes.sortDesc, themes.active ].join(' '); + icon = themes.iconSortDesc; + } + $header.addClass(hdr); + $icon.addClass(icon || ''); + } + } + } + if (c.debug) { + console.log('Applying ' + theme + ' theme' + ts.benchmark(time)); + } + }, + remove: function(table, c, wo, refreshing) { + if (!wo.uitheme_applied) { return; } + var $table = c.$table, + theme = c.appliedTheme || 'jui', + themes = ts.themes[ theme ] || ts.themes.jui, + $headers = $table.children('thead').children(), + remove = themes.sortNone + ' ' + themes.sortDesc + ' ' + themes.sortAsc, + iconRmv = themes.iconSortNone + ' ' + themes.iconSortDesc + ' ' + themes.iconSortAsc; + $table.removeClass('tablesorter-' + theme + ' ' + themes.table); + wo.uitheme_applied = false; + if (refreshing) { return; } + $table.find(ts.css.header).removeClass(themes.header); + $headers + .unbind('mouseenter.tsuitheme mouseleave.tsuitheme') // remove hover + .removeClass(themes.hover + ' ' + remove + ' ' + themes.active) + .filter('.' + ts.css.filterRow) + .removeClass(themes.filterRow); + $headers.find('.' + ts.css.icon).removeClass(themes.icons + ' ' + iconRmv); + } + }); + +})(jQuery); + +/*! Widget: columns - updated 5/24/2017 (v2.28.11) */ +;(function ($) { + 'use strict'; + var ts = $.tablesorter || {}; + + ts.addWidget({ + id: 'columns', + priority: 65, + options : { + columns : [ 'primary', 'secondary', 'tertiary' ] + }, + format: function(table, c, wo) { + var $tbody, tbodyIndex, $rows, rows, $row, $cells, remove, indx, + $table = c.$table, + $tbodies = c.$tbodies, + sortList = c.sortList, + len = sortList.length, + // removed c.widgetColumns support + css = wo && wo.columns || [ 'primary', 'secondary', 'tertiary' ], + last = css.length - 1; + remove = css.join(' '); + // check if there is a sort (on initialization there may not be one) + for (tbodyIndex = 0; tbodyIndex < $tbodies.length; tbodyIndex++ ) { + $tbody = ts.processTbody(table, $tbodies.eq(tbodyIndex), true); // detach tbody + $rows = $tbody.children('tr'); + // loop through the visible rows + $rows.each(function() { + $row = $(this); + if (this.style.display !== 'none') { + // remove all columns class names + $cells = $row.children().removeClass(remove); + // add appropriate column class names + if (sortList && sortList[0]) { + // primary sort column class + $cells.eq(sortList[0][0]).addClass(css[0]); + if (len > 1) { + for (indx = 1; indx < len; indx++) { + // secondary, tertiary, etc sort column classes + $cells.eq(sortList[indx][0]).addClass( css[indx] || css[last] ); + } + } + } + } + }); + ts.processTbody(table, $tbody, false); + } + // add classes to thead and tfoot + rows = wo.columns_thead !== false ? [ 'thead tr' ] : []; + if (wo.columns_tfoot !== false) { + rows.push('tfoot tr'); + } + if (rows.length) { + $rows = $table.find( rows.join(',') ).children().removeClass(remove); + if (len) { + for (indx = 0; indx < len; indx++) { + // add primary. secondary, tertiary, etc sort column classes + $rows.filter('[data-column="' + sortList[indx][0] + '"]').addClass(css[indx] || css[last]); + } + } + } + }, + remove: function(table, c, wo) { + var tbodyIndex, $tbody, + $tbodies = c.$tbodies, + remove = (wo.columns || [ 'primary', 'secondary', 'tertiary' ]).join(' '); + c.$headers.removeClass(remove); + c.$table.children('tfoot').children('tr').children('th, td').removeClass(remove); + for (tbodyIndex = 0; tbodyIndex < $tbodies.length; tbodyIndex++ ) { + $tbody = ts.processTbody(table, $tbodies.eq(tbodyIndex), true); // remove tbody + $tbody.children('tr').each(function() { + $(this).children().removeClass(remove); + }); + ts.processTbody(table, $tbody, false); // restore tbody + } + } + }); + +})(jQuery); + +/*! Widget: filter - updated 7/4/2017 (v2.28.15) *//* + * Requires tablesorter v2.8+ and jQuery 1.7+ + * by Rob Garrison + */ +;( function ( $ ) { + 'use strict'; + var tsf, tsfRegex, + ts = $.tablesorter || {}, + tscss = ts.css, + tskeyCodes = ts.keyCodes; + + $.extend( tscss, { + filterRow : 'tablesorter-filter-row', + filter : 'tablesorter-filter', + filterDisabled : 'disabled', + filterRowHide : 'hideme' + }); + + $.extend( tskeyCodes, { + backSpace : 8, + escape : 27, + space : 32, + left : 37, + down : 40 + }); + + ts.addWidget({ + id: 'filter', + priority: 50, + options : { + filter_cellFilter : '', // css class name added to the filter cell ( string or array ) + filter_childRows : false, // if true, filter includes child row content in the search + filter_childByColumn : false, // ( filter_childRows must be true ) if true = search child rows by column; false = search all child row text grouped + filter_childWithSibs : true, // if true, include matching child row siblings + filter_columnAnyMatch: true, // if true, allows using '#:{query}' in AnyMatch searches ( column:query ) + filter_columnFilters : true, // if true, a filter will be added to the top of each table column + filter_cssFilter : '', // css class name added to the filter row & each input in the row ( tablesorter-filter is ALWAYS added ) + filter_defaultAttrib : 'data-value', // data attribute in the header cell that contains the default filter value + filter_defaultFilter : {}, // add a default column filter type '~{query}' to make fuzzy searches default; '{q1} AND {q2}' to make all searches use a logical AND. + filter_excludeFilter : {}, // filters to exclude, per column + filter_external : '', // jQuery selector string ( or jQuery object ) of external filters + filter_filteredRow : 'filtered', // class added to filtered rows; define in css with "display:none" to hide the filtered-out rows + filter_formatter : null, // add custom filter elements to the filter row + filter_functions : null, // add custom filter functions using this option + filter_hideEmpty : true, // hide filter row when table is empty + filter_hideFilters : false, // collapse filter row when mouse leaves the area + filter_ignoreCase : true, // if true, make all searches case-insensitive + filter_liveSearch : true, // if true, search column content while the user types ( with a delay ) + filter_matchType : { 'input': 'exact', 'select': 'exact' }, // global query settings ('exact' or 'match'); overridden by "filter-match" or "filter-exact" class + filter_onlyAvail : 'filter-onlyAvail', // a header with a select dropdown & this class name will only show available ( visible ) options within the drop down + filter_placeholder : { search : '', select : '' }, // default placeholder text ( overridden by any header 'data-placeholder' setting ) + filter_reset : null, // jQuery selector string of an element used to reset the filters + filter_resetOnEsc : true, // Reset filter input when the user presses escape - normalized across browsers + filter_saveFilters : false, // Use the $.tablesorter.storage utility to save the most recent filters + filter_searchDelay : 300, // typing delay in milliseconds before starting a search + filter_searchFiltered: true, // allow searching through already filtered rows in special circumstances; will speed up searching in large tables if true + filter_selectSource : null, // include a function to return an array of values to be added to the column filter select + filter_selectSourceSeparator : '|', // filter_selectSource array text left of the separator is added to the option value, right into the option text + filter_serversideFiltering : false, // if true, must perform server-side filtering b/c client-side filtering is disabled, but the ui and events will still be used. + filter_startsWith : false, // if true, filter start from the beginning of the cell contents + filter_useParsedData : false // filter all data using parsed content + }, + format: function( table, c, wo ) { + if ( !c.$table.hasClass( 'hasFilters' ) ) { + tsf.init( table, c, wo ); + } + }, + remove: function( table, c, wo, refreshing ) { + var tbodyIndex, $tbody, + $table = c.$table, + $tbodies = c.$tbodies, + events = ( + 'addRows updateCell update updateRows updateComplete appendCache filterReset ' + + 'filterAndSortReset filterFomatterUpdate filterEnd search stickyHeadersInit ' + ).split( ' ' ).join( c.namespace + 'filter ' ); + $table + .removeClass( 'hasFilters' ) + // add filter namespace to all BUT search + .unbind( events.replace( ts.regex.spaces, ' ' ) ) + // remove the filter row even if refreshing, because the column might have been moved + .find( '.' + tscss.filterRow ).remove(); + wo.filter_initialized = false; + if ( refreshing ) { return; } + for ( tbodyIndex = 0; tbodyIndex < $tbodies.length; tbodyIndex++ ) { + $tbody = ts.processTbody( table, $tbodies.eq( tbodyIndex ), true ); // remove tbody + $tbody.children().removeClass( wo.filter_filteredRow ).show(); + ts.processTbody( table, $tbody, false ); // restore tbody + } + if ( wo.filter_reset ) { + $( document ).undelegate( wo.filter_reset, 'click' + c.namespace + 'filter' ); + } + } + }); + + tsf = ts.filter = { + + // regex used in filter 'check' functions - not for general use and not documented + regex: { + regex : /^\/((?:\\\/|[^\/])+)\/([migyu]{0,5})?$/, // regex to test for regex + child : /tablesorter-childRow/, // child row class name; this gets updated in the script + filtered : /filtered/, // filtered (hidden) row class name; updated in the script + type : /undefined|number/, // check type + exact : /(^[\"\'=]+)|([\"\'=]+$)/g, // exact match (allow '==') + operators : /[<>=]/g, // replace operators + query : '(q|query)', // replace filter queries + wild01 : /\?/g, // wild card match 0 or 1 + wild0More : /\*/g, // wild care match 0 or more + quote : /\"/g, + isNeg1 : /(>=?\s*-\d)/, + isNeg2 : /(<=?\s*\d)/ + }, + // function( c, data ) { } + // c = table.config + // data.$row = jQuery object of the row currently being processed + // data.$cells = jQuery object of all cells within the current row + // data.filters = array of filters for all columns ( some may be undefined ) + // data.filter = filter for the current column + // data.iFilter = same as data.filter, except lowercase ( if wo.filter_ignoreCase is true ) + // data.exact = table cell text ( or parsed data if column parser enabled; may be a number & not a string ) + // data.iExact = same as data.exact, except lowercase ( if wo.filter_ignoreCase is true; may be a number & not a string ) + // data.cache = table cell text from cache, so it has been parsed ( & in all lower case if c.ignoreCase is true ) + // data.cacheArray = An array of parsed content from each table cell in the row being processed + // data.index = column index; table = table element ( DOM ) + // data.parsed = array ( by column ) of boolean values ( from filter_useParsedData or 'filter-parsed' class ) + types: { + or : function( c, data, vars ) { + // look for "|", but not if it is inside of a regular expression + if ( ( tsfRegex.orTest.test( data.iFilter ) || tsfRegex.orSplit.test( data.filter ) ) && + // this test for regex has potential to slow down the overall search + !tsfRegex.regex.test( data.filter ) ) { + var indx, filterMatched, query, regex, + // duplicate data but split filter + data2 = $.extend( {}, data ), + filter = data.filter.split( tsfRegex.orSplit ), + iFilter = data.iFilter.split( tsfRegex.orSplit ), + len = filter.length; + for ( indx = 0; indx < len; indx++ ) { + data2.nestedFilters = true; + data2.filter = '' + ( tsf.parseFilter( c, filter[ indx ], data ) || '' ); + data2.iFilter = '' + ( tsf.parseFilter( c, iFilter[ indx ], data ) || '' ); + query = '(' + ( tsf.parseFilter( c, data2.filter, data ) || '' ) + ')'; + try { + // use try/catch, because query may not be a valid regex if "|" is contained within a partial regex search, + // e.g "/(Alex|Aar" -> Uncaught SyntaxError: Invalid regular expression: /(/(Alex)/: Unterminated group + regex = new RegExp( data.isMatch ? query : '^' + query + '$', c.widgetOptions.filter_ignoreCase ? 'i' : '' ); + // filterMatched = data2.filter === '' && indx > 0 ? true + // look for an exact match with the 'or' unless the 'filter-match' class is found + filterMatched = regex.test( data2.exact ) || tsf.processTypes( c, data2, vars ); + if ( filterMatched ) { + return filterMatched; + } + } catch ( error ) { + return null; + } + } + // may be null from processing types + return filterMatched || false; + } + return null; + }, + // Look for an AND or && operator ( logical and ) + and : function( c, data, vars ) { + if ( tsfRegex.andTest.test( data.filter ) ) { + var indx, filterMatched, result, query, regex, + // duplicate data but split filter + data2 = $.extend( {}, data ), + filter = data.filter.split( tsfRegex.andSplit ), + iFilter = data.iFilter.split( tsfRegex.andSplit ), + len = filter.length; + for ( indx = 0; indx < len; indx++ ) { + data2.nestedFilters = true; + data2.filter = '' + ( tsf.parseFilter( c, filter[ indx ], data ) || '' ); + data2.iFilter = '' + ( tsf.parseFilter( c, iFilter[ indx ], data ) || '' ); + query = ( '(' + ( tsf.parseFilter( c, data2.filter, data ) || '' ) + ')' ) + // replace wild cards since /(a*)/i will match anything + .replace( tsfRegex.wild01, '\\S{1}' ).replace( tsfRegex.wild0More, '\\S*' ); + try { + // use try/catch just in case RegExp is invalid + regex = new RegExp( data.isMatch ? query : '^' + query + '$', c.widgetOptions.filter_ignoreCase ? 'i' : '' ); + // look for an exact match with the 'and' unless the 'filter-match' class is found + result = ( regex.test( data2.exact ) || tsf.processTypes( c, data2, vars ) ); + if ( indx === 0 ) { + filterMatched = result; + } else { + filterMatched = filterMatched && result; + } + } catch ( error ) { + return null; + } + } + // may be null from processing types + return filterMatched || false; + } + return null; + }, + // Look for regex + regex: function( c, data ) { + if ( tsfRegex.regex.test( data.filter ) ) { + var matches, + // cache regex per column for optimal speed + regex = data.filter_regexCache[ data.index ] || tsfRegex.regex.exec( data.filter ), + isRegex = regex instanceof RegExp; + try { + if ( !isRegex ) { + // force case insensitive search if ignoreCase option set? + // if ( c.ignoreCase && !regex[2] ) { regex[2] = 'i'; } + data.filter_regexCache[ data.index ] = regex = new RegExp( regex[1], regex[2] ); + } + matches = regex.test( data.exact ); + } catch ( error ) { + matches = false; + } + return matches; + } + return null; + }, + // Look for operators >, >=, < or <= + operators: function( c, data ) { + // ignore empty strings... because '' < 10 is true + if ( tsfRegex.operTest.test( data.iFilter ) && data.iExact !== '' ) { + var cachedValue, result, txt, + table = c.table, + parsed = data.parsed[ data.index ], + query = ts.formatFloat( data.iFilter.replace( tsfRegex.operators, '' ), table ), + parser = c.parsers[ data.index ] || {}, + savedSearch = query; + // parse filter value in case we're comparing numbers ( dates ) + if ( parsed || parser.type === 'numeric' ) { + txt = $.trim( '' + data.iFilter.replace( tsfRegex.operators, '' ) ); + result = tsf.parseFilter( c, txt, data, true ); + query = ( typeof result === 'number' && result !== '' && !isNaN( result ) ) ? result : query; + } + // iExact may be numeric - see issue #149; + // check if cached is defined, because sometimes j goes out of range? ( numeric columns ) + if ( ( parsed || parser.type === 'numeric' ) && !isNaN( query ) && + typeof data.cache !== 'undefined' ) { + cachedValue = data.cache; + } else { + txt = isNaN( data.iExact ) ? data.iExact.replace( ts.regex.nondigit, '' ) : data.iExact; + cachedValue = ts.formatFloat( txt, table ); + } + if ( tsfRegex.gtTest.test( data.iFilter ) ) { + result = tsfRegex.gteTest.test( data.iFilter ) ? cachedValue >= query : cachedValue > query; + } else if ( tsfRegex.ltTest.test( data.iFilter ) ) { + result = tsfRegex.lteTest.test( data.iFilter ) ? cachedValue <= query : cachedValue < query; + } + // keep showing all rows if nothing follows the operator + if ( !result && savedSearch === '' ) { + result = true; + } + return result; + } + return null; + }, + // Look for a not match + notMatch: function( c, data ) { + if ( tsfRegex.notTest.test( data.iFilter ) ) { + var indx, + txt = data.iFilter.replace( '!', '' ), + filter = tsf.parseFilter( c, txt, data ) || ''; + if ( tsfRegex.exact.test( filter ) ) { + // look for exact not matches - see #628 + filter = filter.replace( tsfRegex.exact, '' ); + return filter === '' ? true : $.trim( filter ) !== data.iExact; + } else { + indx = data.iExact.search( $.trim( filter ) ); + return filter === '' ? true : + // return true if not found + data.anyMatch ? indx < 0 : + // return false if found + !( c.widgetOptions.filter_startsWith ? indx === 0 : indx >= 0 ); + } + } + return null; + }, + // Look for quotes or equals to get an exact match; ignore type since iExact could be numeric + exact: function( c, data ) { + /*jshint eqeqeq:false */ + if ( tsfRegex.exact.test( data.iFilter ) ) { + var txt = data.iFilter.replace( tsfRegex.exact, '' ), + filter = tsf.parseFilter( c, txt, data ) || ''; + return data.anyMatch ? $.inArray( filter, data.rowArray ) >= 0 : filter == data.iExact; + } + return null; + }, + // Look for a range ( using ' to ' or ' - ' ) - see issue #166; thanks matzhu! + range : function( c, data ) { + if ( tsfRegex.toTest.test( data.iFilter ) ) { + var result, tmp, range1, range2, + table = c.table, + index = data.index, + parsed = data.parsed[index], + // make sure the dash is for a range and not indicating a negative number + query = data.iFilter.split( tsfRegex.toSplit ); + + tmp = query[0].replace( ts.regex.nondigit, '' ) || ''; + range1 = ts.formatFloat( tsf.parseFilter( c, tmp, data ), table ); + tmp = query[1].replace( ts.regex.nondigit, '' ) || ''; + range2 = ts.formatFloat( tsf.parseFilter( c, tmp, data ), table ); + // parse filter value in case we're comparing numbers ( dates ) + if ( parsed || c.parsers[ index ].type === 'numeric' ) { + result = c.parsers[ index ].format( '' + query[0], table, c.$headers.eq( index ), index ); + range1 = ( result !== '' && !isNaN( result ) ) ? result : range1; + result = c.parsers[ index ].format( '' + query[1], table, c.$headers.eq( index ), index ); + range2 = ( result !== '' && !isNaN( result ) ) ? result : range2; + } + if ( ( parsed || c.parsers[ index ].type === 'numeric' ) && !isNaN( range1 ) && !isNaN( range2 ) ) { + result = data.cache; + } else { + tmp = isNaN( data.iExact ) ? data.iExact.replace( ts.regex.nondigit, '' ) : data.iExact; + result = ts.formatFloat( tmp, table ); + } + if ( range1 > range2 ) { + tmp = range1; range1 = range2; range2 = tmp; // swap + } + return ( result >= range1 && result <= range2 ) || ( range1 === '' || range2 === '' ); + } + return null; + }, + // Look for wild card: ? = single, * = multiple, or | = logical OR + wild : function( c, data ) { + if ( tsfRegex.wildOrTest.test( data.iFilter ) ) { + var query = '' + ( tsf.parseFilter( c, data.iFilter, data ) || '' ); + // look for an exact match with the 'or' unless the 'filter-match' class is found + if ( !tsfRegex.wildTest.test( query ) && data.nestedFilters ) { + query = data.isMatch ? query : '^(' + query + ')$'; + } + // parsing the filter may not work properly when using wildcards =/ + try { + return new RegExp( + query.replace( tsfRegex.wild01, '\\S{1}' ).replace( tsfRegex.wild0More, '\\S*' ), + c.widgetOptions.filter_ignoreCase ? 'i' : '' + ) + .test( data.exact ); + } catch ( error ) { + return null; + } + } + return null; + }, + // fuzzy text search; modified from https://github.com/mattyork/fuzzy ( MIT license ) + fuzzy: function( c, data ) { + if ( tsfRegex.fuzzyTest.test( data.iFilter ) ) { + var indx, + patternIndx = 0, + len = data.iExact.length, + txt = data.iFilter.slice( 1 ), + pattern = tsf.parseFilter( c, txt, data ) || ''; + for ( indx = 0; indx < len; indx++ ) { + if ( data.iExact[ indx ] === pattern[ patternIndx ] ) { + patternIndx += 1; + } + } + return patternIndx === pattern.length; + } + return null; + } + }, + init: function( table ) { + // filter language options + ts.language = $.extend( true, {}, { + to : 'to', + or : 'or', + and : 'and' + }, ts.language ); + + var options, string, txt, $header, column, val, fxn, noSelect, + c = table.config, + wo = c.widgetOptions; + c.$table.addClass( 'hasFilters' ); + c.lastSearch = []; + + // define timers so using clearTimeout won't cause an undefined error + wo.filter_searchTimer = null; + wo.filter_initTimer = null; + wo.filter_formatterCount = 0; + wo.filter_formatterInit = []; + wo.filter_anyColumnSelector = '[data-column="all"],[data-column="any"]'; + wo.filter_multipleColumnSelector = '[data-column*="-"],[data-column*=","]'; + + val = '\\{' + tsfRegex.query + '\\}'; + $.extend( tsfRegex, { + child : new RegExp( c.cssChildRow ), + filtered : new RegExp( wo.filter_filteredRow ), + alreadyFiltered : new RegExp( '(\\s+(' + ts.language.or + '|-|' + ts.language.to + ')\\s+)', 'i' ), + toTest : new RegExp( '\\s+(-|' + ts.language.to + ')\\s+', 'i' ), + toSplit : new RegExp( '(?:\\s+(?:-|' + ts.language.to + ')\\s+)', 'gi' ), + andTest : new RegExp( '\\s+(' + ts.language.and + '|&&)\\s+', 'i' ), + andSplit : new RegExp( '(?:\\s+(?:' + ts.language.and + '|&&)\\s+)', 'gi' ), + orTest : new RegExp( '(\\||\\s+' + ts.language.or + '\\s+)', 'i' ), + orSplit : new RegExp( '(?:\\s+(?:' + ts.language.or + ')\\s+|\\|)', 'gi' ), + iQuery : new RegExp( val, 'i' ), + igQuery : new RegExp( val, 'ig' ), + operTest : /^[<>]=?/, + gtTest : />/, + gteTest : />=/, + ltTest : /' + + ( $header.data( 'placeholder' ) || + $header.attr( 'data-placeholder' ) || + wo.filter_placeholder.select || + '' + ) + + '' : ''; + val = string; + txt = string; + if ( string.indexOf( wo.filter_selectSourceSeparator ) >= 0 ) { + val = string.split( wo.filter_selectSourceSeparator ); + txt = val[1]; + val = val[0]; + } + options += ''; + } + } + c.$table + .find( 'thead' ) + .find( 'select.' + tscss.filter + '[data-column="' + column + '"]' ) + .append( options ); + txt = wo.filter_selectSource; + fxn = typeof txt === 'function' ? true : ts.getColumnData( table, txt, column ); + if ( fxn ) { + // updating so the extra options are appended + tsf.buildSelect( c.table, column, '', true, $header.hasClass( wo.filter_onlyAvail ) ); + } + } + } + } + } + // not really updating, but if the column has both the 'filter-select' class & + // filter_functions set to true, it would append the same options twice. + tsf.buildDefault( table, true ); + + tsf.bindSearch( table, c.$table.find( '.' + tscss.filter ), true ); + if ( wo.filter_external ) { + tsf.bindSearch( table, wo.filter_external ); + } + + if ( wo.filter_hideFilters ) { + tsf.hideFilters( c ); + } + + // show processing icon + if ( c.showProcessing ) { + txt = 'filterStart filterEnd '.split( ' ' ).join( c.namespace + 'filter ' ); + c.$table + .unbind( txt.replace( ts.regex.spaces, ' ' ) ) + .bind( txt, function( event, columns ) { + // only add processing to certain columns to all columns + $header = ( columns ) ? + c.$table + .find( '.' + tscss.header ) + .filter( '[data-column]' ) + .filter( function() { + return columns[ $( this ).data( 'column' ) ] !== ''; + }) : ''; + ts.isProcessing( table, event.type === 'filterStart', columns ? $header : '' ); + }); + } + + // set filtered rows count ( intially unfiltered ) + c.filteredRows = c.totalRows; + + // add default values + txt = 'tablesorter-initialized pagerBeforeInitialized '.split( ' ' ).join( c.namespace + 'filter ' ); + c.$table + .unbind( txt.replace( ts.regex.spaces, ' ' ) ) + .bind( txt, function() { + tsf.completeInit( this ); + }); + // if filter widget is added after pager has initialized; then set filter init flag + if ( c.pager && c.pager.initialized && !wo.filter_initialized ) { + c.$table.triggerHandler( 'filterFomatterUpdate' ); + setTimeout( function() { + tsf.filterInitComplete( c ); + }, 100 ); + } else if ( !wo.filter_initialized ) { + tsf.completeInit( table ); + } + }, + completeInit: function( table ) { + // redefine 'c' & 'wo' so they update properly inside this callback + var c = table.config, + wo = c.widgetOptions, + filters = tsf.setDefaults( table, c, wo ) || []; + if ( filters.length ) { + // prevent delayInit from triggering a cache build if filters are empty + if ( !( c.delayInit && filters.join( '' ) === '' ) ) { + ts.setFilters( table, filters, true ); + } + } + c.$table.triggerHandler( 'filterFomatterUpdate' ); + // trigger init after setTimeout to prevent multiple filterStart/End/Init triggers + setTimeout( function() { + if ( !wo.filter_initialized ) { + tsf.filterInitComplete( c ); + } + }, 100 ); + }, + + // $cell parameter, but not the config, is passed to the filter_formatters, + // so we have to work with it instead + formatterUpdated: function( $cell, column ) { + // prevent error if $cell is undefined - see #1056 + var $table = $cell && $cell.closest( 'table' ); + var config = $table.length && $table[0].config, + wo = config && config.widgetOptions; + if ( wo && !wo.filter_initialized ) { + // add updates by column since this function + // may be called numerous times before initialization + wo.filter_formatterInit[ column ] = 1; + } + }, + filterInitComplete: function( c ) { + var indx, len, + wo = c.widgetOptions, + count = 0, + completed = function() { + wo.filter_initialized = true; + // update lastSearch - it gets cleared often + c.lastSearch = c.$table.data( 'lastSearch' ); + c.$table.triggerHandler( 'filterInit', c ); + tsf.findRows( c.table, c.lastSearch || [] ); + }; + if ( $.isEmptyObject( wo.filter_formatter ) ) { + completed(); + } else { + len = wo.filter_formatterInit.length; + for ( indx = 0; indx < len; indx++ ) { + if ( wo.filter_formatterInit[ indx ] === 1 ) { + count++; + } + } + clearTimeout( wo.filter_initTimer ); + if ( !wo.filter_initialized && count === wo.filter_formatterCount ) { + // filter widget initialized + completed(); + } else if ( !wo.filter_initialized ) { + // fall back in case a filter_formatter doesn't call + // $.tablesorter.filter.formatterUpdated( $cell, column ), and the count is off + wo.filter_initTimer = setTimeout( function() { + completed(); + }, 500 ); + } + } + }, + // encode or decode filters for storage; see #1026 + processFilters: function( filters, encode ) { + var indx, + // fixes #1237; previously returning an encoded "filters" value + result = [], + mode = encode ? encodeURIComponent : decodeURIComponent, + len = filters.length; + for ( indx = 0; indx < len; indx++ ) { + if ( filters[ indx ] ) { + result[ indx ] = mode( filters[ indx ] ); + } + } + return result; + }, + setDefaults: function( table, c, wo ) { + var isArray, saved, indx, col, $filters, + // get current ( default ) filters + filters = ts.getFilters( table ) || []; + if ( wo.filter_saveFilters && ts.storage ) { + saved = ts.storage( table, 'tablesorter-filters' ) || []; + isArray = $.isArray( saved ); + // make sure we're not just getting an empty array + if ( !( isArray && saved.join( '' ) === '' || !isArray ) ) { + filters = tsf.processFilters( saved ); + } + } + // if no filters saved, then check default settings + if ( filters.join( '' ) === '' ) { + // allow adding default setting to external filters + $filters = c.$headers.add( wo.filter_$externalFilters ) + .filter( '[' + wo.filter_defaultAttrib + ']' ); + for ( indx = 0; indx <= c.columns; indx++ ) { + // include data-column='all' external filters + col = indx === c.columns ? 'all' : indx; + filters[ indx ] = $filters + .filter( '[data-column="' + col + '"]' ) + .attr( wo.filter_defaultAttrib ) || filters[indx] || ''; + } + } + c.$table.data( 'lastSearch', filters ); + return filters; + }, + parseFilter: function( c, filter, data, parsed ) { + return parsed || data.parsed[ data.index ] ? + c.parsers[ data.index ].format( filter, c.table, [], data.index ) : + filter; + }, + buildRow: function( table, c, wo ) { + var $filter, col, column, $header, makeSelect, disabled, name, ffxn, tmp, + // c.columns defined in computeThIndexes() + cellFilter = wo.filter_cellFilter, + columns = c.columns, + arry = $.isArray( cellFilter ), + buildFilter = '
    '; + for ( column = 0; column < columns; column++ ) { + if ( c.$headerIndexed[ column ].length ) { + // account for entire column set with colspan. See #1047 + tmp = c.$headerIndexed[ column ] && c.$headerIndexed[ column ][0].colSpan || 0; + if ( tmp > 1 ) { + buildFilter += '
    diff --git a/gestioncof/templates/login.html b/gestioncof/templates/login.html index 1cd1d25d..bfc2dbb8 100644 --- a/gestioncof/templates/login.html +++ b/gestioncof/templates/login.html @@ -15,7 +15,7 @@

    Identifiants incorrects.

    {% endif %} + action="{% url 'ext_login_view' %}?next={{ next|urlencode }}"> {% csrf_token %}
    diff --git a/gestioncof/templates/login_switch.html b/gestioncof/templates/login_switch.html index aa8a68c6..d361493b 100644 --- a/gestioncof/templates/login_switch.html +++ b/gestioncof/templates/login_switch.html @@ -12,13 +12,13 @@
    + href="{% url 'cas_login_view' %}?next={{ next|urlencode }}">
    Compte clipper
    + href="{% url 'ext_login_view' %}?next={{ next|urlencode }}">
    Extérieur
    diff --git a/gestioncof/templates/registration.html b/gestioncof/templates/registration.html index 8f05dfb0..6e6421cc 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 %} @@ -18,7 +18,7 @@ // On attend que la page soit prête pour executer le code $(document).ready(function() { $('input#search_autocomplete').yourlabsAutocomplete({ - url: '{% url 'gestioncof.autocomplete.autocomplete' %}', + url: '{% url 'cof.registration.autocomplete' %}', minimumCharacters: 3, id: 'search_autocomplete', choiceSelector: 'li:has(a)', diff --git a/gestioncof/templates/registration/password_change_done.html b/gestioncof/templates/registration/password_change_done.html index f83a781b..9f2c4a60 100644 --- a/gestioncof/templates/registration/password_change_done.html +++ b/gestioncof/templates/registration/password_change_done.html @@ -5,5 +5,5 @@ {% block realcontent %}

    Mot de passe modifié avec succès !

    -

    Retour au menu principal

    +

    Retour au menu principal

    {% endblock %} diff --git a/gestioncof/templates/registration/password_change_form.html b/gestioncof/templates/registration/password_change_form.html index f579fb31..d9a3f66a 100644 --- a/gestioncof/templates/registration/password_change_form.html +++ b/gestioncof/templates/registration/password_change_form.html @@ -5,7 +5,7 @@ {% block realcontent %}

    Changement de mot de passe

    - + {% 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 }} diff --git a/gestioncof/templates/utile_bda.html b/gestioncof/templates/utile_bda.html index 8948de97..11e96f72 100644 --- a/gestioncof/templates/utile_bda.html +++ b/gestioncof/templates/utile_bda.html @@ -7,7 +7,7 @@

    Liens utiles du BdA

    Listes mail

    {% endblock %} diff --git a/gestioncof/templatetags/utils.py b/gestioncof/templatetags/utils.py index 76bc6003..2b732aec 100644 --- a/gestioncof/templatetags/utils.py +++ b/gestioncof/templatetags/utils.py @@ -1,9 +1,3 @@ -# -*- coding: utf-8 -*- - -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/__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 87% rename from gestioncof/tests.py rename to gestioncof/tests/test_legacy.py index 66043daf..85673edd 100644 --- a/gestioncof/tests.py +++ b/gestioncof/tests/test_legacy.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". @@ -6,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/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..47139327 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,874 @@ +import csv +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 Client, TestCase +from django.urls import reverse + +from bda.models import Salle, Tirage +from gestioncof.models import ( + CalendarSubscription, Club, Event, Survey, SurveyAnswer +) +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 = 'ml_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) + + +class ExportMembersViewTests(ViewTestCaseMixin, TestCase): + url_name = 'cof.membres_export' + 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])) + expected = [ + [ + str(u1.pk), 'member', 'first', 'last', 'user@mail.net', + '0123456789', '1A', 'Dept', 'normalien', + ], + [str(u2.pk), 'staff', '', '', '', '', '1A', '', 'normalien'], + ] + # Sort before checking equality, the order of the output of csv.reader + # does not seem deterministic + expected.sort(key=lambda row: int(row[0])) + data.sort(key=lambda row: int(row[0])) + self.assertListEqual(data, expected) + + +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 = 'cof.mega_export' + 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 = 'cof.mega_export_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 = 'cof.mega_export_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 = 'cof.mega_export_remarks' + 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', + ], + ]) + + +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()) + + +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 = '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), + }, + ]) + + +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]) + + +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]) 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..7ba361b7 --- /dev/null +++ b/gestioncof/tests/utils.py @@ -0,0 +1,61 @@ +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', 'is_superuser', + ] + 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) + + +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) diff --git a/gestioncof/urls.py b/gestioncof/urls.py index 9a562e7e..f8ce8f6d 100644 --- a/gestioncof/urls.py +++ b/gestioncof/urls.py @@ -1,17 +1,20 @@ -# -*- coding: utf-8 -*- - from django.conf.urls import url from gestioncof.petits_cours_views import DemandeListView, DemandeDetailView 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'^mega/(?P.+)$', views.export_mega_bytype), - url(r'^mega$', views.export_mega), + 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, + name='cof.mega_export'), ] petitcours_patterns = [ @@ -36,19 +39,24 @@ petitcours_patterns = [ ] surveys_patterns = [ - url(r'^(?P\d+)/status$', views.survey_status), - url(r'^(?P\d+)$', views.survey), + url(r'^(?P\d+)/status$', views.survey_status, + name='survey.details.status'), + url(r'^(?P\d+)$', views.survey, + name='survey.details'), ] events_patterns = [ - url(r'^(?P\d+)$', views.event), - url(r'^(?P\d+)/status$', views.event_status), + url(r'^(?P\d+)$', views.event, + name='event.details'), + url(r'^(?P\d+)/status$', views.event_status, + name='event.details.status'), ] calendar_patterns = [ - url(r'^subscription$', 'gestioncof.views.calendar'), - url(r'^(?P[a-z0-9-]+)/calendar.ics$', - 'gestioncof.views.calendar_ics') + url(r'^subscription$', views.calendar, + name='calendar'), + url(r'^(?P[a-z0-9-]+)/calendar.ics$', views.calendar_ics, + name='calendar.ics'), ] clubs_patterns = [ diff --git a/gestioncof/views.py b/gestioncof/views.py index 457a99c4..6126eb10 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import unicodecsv import uuid from datetime import timedelta @@ -9,12 +7,21 @@ from custommail.shortcuts import send_custom_mail from django.shortcuts import redirect, get_object_or_404, render 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 +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 django.core.urlresolvers import reverse_lazy +from django.views.generic import FormView from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ from django.contrib import messages -import django.utils.six as six + +from django_cas_ng.views import logout as cas_logout_view + +from utils.views.autocomplete import Select2QuerySetView from gestioncof.models import Survey, SurveyAnswer, SurveyQuestion, \ SurveyQuestionAnswer @@ -24,10 +31,11 @@ from gestioncof.models import EventCommentField, EventCommentValue, \ CalendarSubscription from gestioncof.models import CofProfile, Club from gestioncof.decorators import buro_required, cof_required -from gestioncof.forms import UserProfileForm, EventStatusFilterForm, \ - SurveyForm, SurveyStatusFilterForm, RegistrationUserForm, \ - RegistrationProfileForm, EventForm, CalendarForm, EventFormset, \ - RegistrationPassUserForm, ClubsForm +from gestioncof.forms import ( + UserProfileForm, EventStatusFilterForm, SurveyForm, SurveyStatusFilterForm, + RegistrationUserForm, RegistrationProfileForm, EventForm, CalendarForm, + EventFormset, RegistrationPassUserForm, ClubsForm, GestioncofConfigForm +) from bda.models import Tirage, Spectacle @@ -49,8 +57,8 @@ def home(request): def login(request): - if request.user.is_authenticated(): - return redirect("gestioncof.views.home") + if request.user.is_authenticated: + return redirect("home") context = {} if request.method == "GET" and 'next' in request.GET: context['next'] = request.GET['next'] @@ -81,15 +89,21 @@ def login_ext(request): @login_required -def logout(request): - try: - profile = request.user.profile - except CofProfile.DoesNotExist: - profile, created = CofProfile.objects.get_or_create(user=request.user) - if profile.login_clipper: - return redirect("django_cas_ng.views.logout") +def logout(request, next_page=None): + if next_page is None: + next_page = request.GET.get('next', None) + + profile = getattr(request.user, 'profile', None) + + if profile and profile.login_clipper: + msg = _('Déconnexion de GestioCOF et CAS réussie. À bientôt {}.') + logout_view = cas_logout_view else: - return redirect("django.contrib.auth.views.logout") + msg = _('Déconnexion de GestioCOF réussie. À bientôt {}.') + logout_view = django_logout_view + + messages.success(request, msg.format(request.user.get_short_name())) + return logout_view(request, next_page=next_page) @login_required @@ -325,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}) @@ -387,7 +401,6 @@ def registration_form2(request, login_clipper=None, username=None, elif not login_clipper: # new user user_form = RegistrationPassUserForm() - user_form.force_long_username() profile_form = RegistrationProfileForm() event_formset = EventFormset(events=events, prefix='events') clubs_form = ClubsForm() @@ -403,12 +416,8 @@ def registration_form2(request, login_clipper=None, username=None, def registration(request): if request.POST: request_dict = request.POST.copy() - # num ne peut pas être défini manuellement - if "num" in request_dict: - del request_dict["num"] member = None login_clipper = None - success = False # ----- # Remplissage des formulaires @@ -430,12 +439,10 @@ def registration(request): user_form = RegistrationUserForm(request_dict, instance=member) if member.profile.login_clipper: login_clipper = member.profile.login_clipper - else: - user_form.force_long_username() except User.DoesNotExist: - user_form.force_long_username() + pass else: - user_form.force_long_username() + pass # ----- # Validation des formulaires @@ -445,7 +452,6 @@ def registration(request): member = user_form.save() profile, _ = CofProfile.objects.get_or_create(user=member) was_cof = profile.is_cof - request_dict["num"] = profile.num # Maintenant on remplit le formulaire de profil profile_form = RegistrationProfileForm(request_dict, instance=profile) @@ -499,16 +505,18 @@ def registration(request): for club in clubs_form.cleaned_data['clubs']: club.membres.add(member) club.save() - success = True - # Messages - if success: - msg = ("L'inscription de {:s} ({:s}) a été " - "enregistrée avec succès" - .format(member.get_full_name(), member.email)) - if member.profile.is_cof: - msg += "Il est désormais membre du COF n°{:d} !".format( - member.profile.num) - messages.success(request, msg, extra_tags='safe') + + # --- + # Success + # --- + + msg = ("L'inscription de {:s} ({:s}) a été " + "enregistrée avec succès." + .format(member.get_full_name(), member.email)) + if profile.is_cof: + msg += "\nIl est désormais membre du COF n°{:d} !".format( + member.profile.id) + messages.success(request, msg, extra_tags='safe') return render(request, "gestioncof/registration_post.html", {"user_form": user_form, "profile_form": profile_form, @@ -559,7 +567,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) @@ -572,10 +580,10 @@ def export_members(request): writer = unicodecsv.writer(response) for profile in CofProfile.objects.filter(is_cof=True).all(): user = profile.user - bits = [profile.num, user.username, user.first_name, user.last_name, + bits = [user.id, user.username, user.first_name, user.last_name, user.email, profile.phone, profile.occupation, profile.departement, profile.type_cotiz] - writer.writerow([six.text_type(bit) for bit in bits]) + writer.writerow([str(bit) for bit in bits]) return response @@ -591,78 +599,80 @@ def csv_export_mega(filename, qs): comments = "---".join( [comment.content for comment in reg.comments.all()]) bits = [user.username, user.first_name, user.last_name, user.email, - profile.phone, profile.num, + profile.phone, user.id, profile.comments if profile.comments else "", comments] - writer.writerow([six.text_type(bit) for bit in bits]) + writer.writerow([str(bit) for bit in bits]) return response @buro_required def export_mega_remarksonly(request): - filename = 'remarques_mega_2016.csv' + filename = 'remarques_mega_2017.csv' response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename=' + filename writer = unicodecsv.writer(response) - event = Event.objects.get(title="Mega 2016") - commentfield = event.commentfields.get(name="Commentaires") + event = Event.objects.get(title="MEGA 2017") + commentfield = event.commentfields.get(name="Commentaire") for val in commentfield.values.all(): reg = val.registration user = reg.user profile = user.profile bits = [user.username, user.first_name, user.last_name, user.email, - profile.phone, profile.num, profile.comments, val.content] - writer.writerow([six.text_type(bit) for bit in bits]) + profile.phone, profile.id, profile.comments, val.content] + writer.writerow([str(bit) for bit in bits]) return response -@buro_required -def export_mega_bytype(request, type): - types = {"orga-actif": "Orga élève", - "orga-branleur": "Orga étudiant", - "conscrit-eleve": "Conscrit élève", - "conscrit-etudiant": "Conscrit étudiant"} - - if type not in types: - raise Http404 - - event = Event.objects.get(title="Mega 2016") - type_option = event.options.get(name="Type") - participant_type = type_option.choices.get(value=types[type]).id - qs = EventRegistration.objects.filter(event=event).filter( - options__id__exact=participant_type) - return csv_export_mega(type + '_mega_2016.csv', qs) +# @buro_required +# def export_mega_bytype(request, type): +# types = {"orga-actif": "Orga élève", +# "orga-branleur": "Orga étudiant", +# "conscrit-eleve": "Conscrit élève", +# "conscrit-etudiant": "Conscrit étudiant"} +# +# if type not in types: +# raise Http404 +# +# event = Event.objects.get(title="MEGA 2017") +# type_option = event.options.get(name="Type") +# participant_type = type_option.choices.get(value=types[type]).id +# qs = EventRegistration.objects.filter(event=event).filter( +# options__id__exact=participant_type) +# return csv_export_mega(type + '_mega_2017.csv', qs) @buro_required def export_mega_orgas(request): - event = Event.objects.get(title="Mega 2016") - type_option = event.options.get(name="Conscrit ou orga ?") - participant_type = type_option.choices.get(value="Vieux").id - qs = EventRegistration.objects.filter(event=event).exclude( - options__id=participant_type) - return csv_export_mega('orgas_mega_2016.csv', qs) + event = Event.objects.get(title="MEGA 2017") + type_option = event.options.get(name="Conscrit/Orga ?") + participant_type = type_option.choices.get(value="Orga").id + qs = EventRegistration.objects.filter(event=event).filter( + options__id=participant_type + ) + return csv_export_mega('orgas_mega_2017.csv', qs) @buro_required def export_mega_participants(request): - event = Event.objects.get(title="Mega 2016") - type_option = event.options.get(name="Conscrit ou orga ?") + event = Event.objects.get(title="MEGA 2017") + type_option = event.options.get(name="Conscrit/Orga ?") participant_type = type_option.choices.get(value="Conscrit").id qs = EventRegistration.objects.filter(event=event).filter( - options__id=participant_type) - return csv_export_mega('participants_mega_2016.csv', qs) + options__id=participant_type + ) + return csv_export_mega('participants_mega_2017.csv', qs) @buro_required def export_mega(request): - event = Event.objects.filter(title="Mega 2016") + event = Event.objects.filter(title="MEGA 2017") qs = EventRegistration.objects.filter(event=event) \ .order_by("user__username") - return csv_export_mega('all_mega_2016.csv', qs) + return csv_export_mega('all_mega_2017.csv', qs) @buro_required @@ -764,3 +774,33 @@ def calendar_ics(request, token): response = HttpResponse(content=vcal.to_ical()) response['Content-Type'] = "text/calendar" return response + + +class ConfigUpdate(FormView): + form_class = GestioncofConfigForm + template_name = "gestioncof/banner_update.html" + success_url = reverse_lazy("home") + + def dispatch(self, request, *args, **kwargs): + if request.user is None or not request.user.is_superuser: + return redirect_to_login(request.get_full_path()) + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + form.save() + return super().form_valid(form) + + +## +# Autocomplete views +# +# https://django-autocomplete-light.readthedocs.io/en/master/tutorial.html#create-an-autocomplete-view +## + + +class UserAutocomplete(Select2QuerySetView): + model = User + search_fields = ('username', 'first_name', 'last_name') + + +user_autocomplete = buro_required(UserAutocomplete.as_view()) diff --git a/gestioncof/widgets.py b/gestioncof/widgets.py index 758fc4ad..906f7b15 100644 --- a/gestioncof/widgets.py +++ b/gestioncof/widgets.py @@ -1,9 +1,3 @@ -# -*- coding: utf-8 -*- - -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 @@ -11,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. @@ -20,6 +14,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)) diff --git a/kfet/apps.py b/kfet/apps.py index 3dd2c0e8..7a6c97a2 100644 --- a/kfet/apps.py +++ b/kfet/apps.py @@ -1,17 +1,11 @@ -# -*- coding: utf-8 -*- - -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * - from django.apps import AppConfig + class KFetConfig(AppConfig): name = 'kfet' verbose_name = "Application K-Fêt" def ready(self): - import kfet.signals self.register_config() def register_config(self): diff --git a/kfet/auth/__init__.py b/kfet/auth/__init__.py new file mode 100644 index 00000000..00926030 --- /dev/null +++ b/kfet/auth/__init__.py @@ -0,0 +1,4 @@ +default_app_config = 'kfet.auth.apps.KFetAuthConfig' + +KFET_GENERIC_USERNAME = 'kfet_genericteam' +KFET_GENERIC_TRIGRAMME = 'GNR' diff --git a/kfet/auth/apps.py b/kfet/auth/apps.py new file mode 100644 index 00000000..d91931f5 --- /dev/null +++ b/kfet/auth/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig +from django.db.models.signals import post_migrate +from django.utils.translation import ugettext_lazy as _ + + +class KFetAuthConfig(AppConfig): + name = 'kfet.auth' + label = 'kfetauth' + verbose_name = _("K-Fêt - Authentification et Autorisation") + + def ready(self): + from . import signals # noqa + from .utils import setup_kfet_generic_user + post_migrate.connect(setup_kfet_generic_user, sender=self) diff --git a/kfet/auth/backends.py b/kfet/auth/backends.py new file mode 100644 index 00000000..d8ef3001 --- /dev/null +++ b/kfet/auth/backends.py @@ -0,0 +1,42 @@ +from django.contrib.auth import get_user_model +from kfet.models import Account, GenericTeamToken + +from .utils import get_kfet_generic_user + +User = get_user_model() + + +class BaseKFetBackend: + def get_user(self, user_id): + """ + Add extra select related up to Account. + """ + try: + return ( + User.objects + .select_related('profile__account_kfet') + .get(pk=user_id) + ) + except User.DoesNotExist: + return None + + +class AccountBackend(BaseKFetBackend): + def authenticate(self, request, kfet_password=None): + try: + return Account.objects.get_by_password(kfet_password).user + except Account.DoesNotExist: + return None + + +class GenericBackend(BaseKFetBackend): + def authenticate(self, request, kfet_token=None): + try: + team_token = GenericTeamToken.objects.get(token=kfet_token) + except GenericTeamToken.DoesNotExist: + return + + # No need to keep the token. + team_token.delete() + + return get_kfet_generic_user() diff --git a/kfet/auth/context_processors.py b/kfet/auth/context_processors.py new file mode 100644 index 00000000..7b59b88b --- /dev/null +++ b/kfet/auth/context_processors.py @@ -0,0 +1,10 @@ +from django.contrib.auth.context_processors import PermWrapper + + +def temporary_auth(request): + if hasattr(request, 'real_user'): + return { + 'user': request.real_user, + 'perms': PermWrapper(request.real_user), + } + return {} diff --git a/kfet/auth/fields.py b/kfet/auth/fields.py new file mode 100644 index 00000000..28ba1c9e --- /dev/null +++ b/kfet/auth/fields.py @@ -0,0 +1,20 @@ +from django import forms +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.forms import widgets + + +class KFetPermissionsField(forms.ModelMultipleChoiceField): + + def __init__(self, *args, **kwargs): + queryset = Permission.objects.filter( + content_type__in=ContentType.objects.filter(app_label="kfet"), + ) + super().__init__( + queryset=queryset, + widget=widgets.CheckboxSelectMultiple, + *args, **kwargs + ) + + def label_from_instance(self, obj): + return obj.name diff --git a/kfet/auth/forms.py b/kfet/auth/forms.py new file mode 100644 index 00000000..876e8814 --- /dev/null +++ b/kfet/auth/forms.py @@ -0,0 +1,48 @@ +from django import forms +from django.contrib.auth.models import Group, User + +from .fields import KFetPermissionsField + + +class GroupForm(forms.ModelForm): + permissions = KFetPermissionsField() + + def clean_name(self): + name = self.cleaned_data['name'] + return 'K-Fêt %s' % name + + def clean_permissions(self): + kfet_perms = self.cleaned_data['permissions'] + # TODO: With Django >=1.11, the QuerySet method 'difference' can be + # used. + # other_groups = self.instance.permissions.difference( + # self.fields['permissions'].queryset + # ) + if self.instance.pk is None: + return kfet_perms + other_perms = self.instance.permissions.exclude( + pk__in=[p.pk for p in self.fields['permissions'].queryset], + ) + return list(kfet_perms) + list(other_perms) + + class Meta: + model = Group + fields = ['name', 'permissions'] + + +class UserGroupForm(forms.ModelForm): + groups = forms.ModelMultipleChoiceField( + Group.objects.filter(name__icontains='K-Fêt'), + label='Statut équipe', + required=False) + + def clean_groups(self): + kfet_groups = self.cleaned_data.get('groups') + if self.instance.pk is None: + return kfet_groups + other_groups = self.instance.groups.exclude(name__icontains='K-Fêt') + return list(kfet_groups) + list(other_groups) + + class Meta: + model = User + fields = ['groups'] diff --git a/kfet/auth/middleware.py b/kfet/auth/middleware.py new file mode 100644 index 00000000..2f3bd33b --- /dev/null +++ b/kfet/auth/middleware.py @@ -0,0 +1,42 @@ +from django.contrib.auth import get_user_model + +from .backends import AccountBackend + +User = get_user_model() + + +class TemporaryAuthMiddleware: + """Authenticate another user for this request if AccountBackend succeeds. + + By the way, if a user is authenticated, we refresh its from db to add + values from CofProfile and Account of this user. + + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.user.is_authenticated: + # avoid multiple db accesses in views and templates + request.user = ( + User.objects + .select_related('profile__account_kfet') + .get(pk=request.user.pk) + ) + + temp_request_user = AccountBackend().authenticate( + request, + kfet_password=self.get_kfet_password(request), + ) + + if temp_request_user: + request.real_user = request.user + request.user = temp_request_user + + return self.get_response(request) + + def get_kfet_password(self, request): + return ( + request.META.get('HTTP_KFETPASSWORD') or + request.POST.get('KFETPASSWORD') + ) diff --git a/kfet/auth/migrations/0001_initial.py b/kfet/auth/migrations/0001_initial.py new file mode 100644 index 00000000..061570a8 --- /dev/null +++ b/kfet/auth/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0006_require_contenttypes_0002'), + # Following dependency allows using Account model to set up the kfet + # generic user in post_migrate receiver. + ('kfet', '0058_delete_genericteamtoken'), + ] + + operations = [ + migrations.CreateModel( + name='GenericTeamToken', + fields=[ + ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), + ('token', models.CharField(unique=True, max_length=50)), + ], + ), + ] diff --git a/kfet/auth/migrations/__init__.py b/kfet/auth/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kfet/auth/models.py b/kfet/auth/models.py new file mode 100644 index 00000000..ecd40091 --- /dev/null +++ b/kfet/auth/models.py @@ -0,0 +1,17 @@ +from django.db import models +from django.utils.crypto import get_random_string + + +class GenericTeamTokenManager(models.Manager): + + def create_token(self): + token = get_random_string(50) + while self.filter(token=token).exists(): + token = get_random_string(50) + return self.create(token=token) + + +class GenericTeamToken(models.Model): + token = models.CharField(max_length=50, unique=True) + + objects = GenericTeamTokenManager() diff --git a/kfet/auth/signals.py b/kfet/auth/signals.py new file mode 100644 index 00000000..3d7af18b --- /dev/null +++ b/kfet/auth/signals.py @@ -0,0 +1,40 @@ +from django.contrib import messages +from django.contrib.auth.signals import user_logged_in +from django.core.urlresolvers import reverse +from django.dispatch import receiver +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext as _ + +from .utils import get_kfet_generic_user + + +@receiver(user_logged_in) +def suggest_auth_generic(sender, request, user, **kwargs): + """ + Suggest logged in user to continue as the kfet generic user. + + Message is only added if the following conditions are met: + - the next page (where user is going to be redirected due to successful + authentication) is related to kfet, i.e. 'k-fet' is in its url. + - logged in user is a kfet staff member (except the generic user). + """ + # Filter against the next page. + if not(hasattr(request, 'GET') and 'next' in request.GET): + return + + next_page = request.GET['next'] + generic_url = reverse('kfet.login.generic') + + if not('k-fet' in next_page and not next_page.startswith(generic_url)): + return + + # Filter against the logged in user. + if not(user.has_perm('kfet.is_team') and user != get_kfet_generic_user()): + return + + # Seems legit to add message. + text = _("K-Fêt — Ouvrir une session partagée ?") + messages.info(request, mark_safe( + '{}' + .format(generic_url, text) + )) diff --git a/kfet/auth/tests.py b/kfet/auth/tests.py new file mode 100644 index 00000000..62f870e8 --- /dev/null +++ b/kfet/auth/tests.py @@ -0,0 +1,368 @@ +from unittest import mock + +from django.core import signing +from django.core.urlresolvers import reverse +from django.contrib.auth.models import AnonymousUser, Group, Permission, User +from django.test import RequestFactory, TestCase + +from kfet.forms import UserGroupForm +from kfet.models import Account + +from . import KFET_GENERIC_TRIGRAMME, KFET_GENERIC_USERNAME +from .backends import AccountBackend, GenericBackend +from .middleware import TemporaryAuthMiddleware +from .models import GenericTeamToken +from .utils import get_kfet_generic_user +from .views import GenericLoginView + + +## +# Forms +## + +class UserGroupFormTests(TestCase): + """Test suite for UserGroupForm.""" + + def setUp(self): + # create user + self.user = User.objects.create(username="foo", password="foo") + + # create some K-Fêt groups + prefix_name = "K-Fêt " + names = ["Group 1", "Group 2", "Group 3"] + self.kfet_groups = [ + Group.objects.create(name=prefix_name+name) + for name in names + ] + + # create a non-K-Fêt group + self.other_group = Group.objects.create(name="Other group") + + def test_choices(self): + """Only K-Fêt groups are selectable.""" + form = UserGroupForm(instance=self.user) + groups_field = form.fields['groups'] + self.assertQuerysetEqual( + groups_field.queryset, + [repr(g) for g in self.kfet_groups], + ordered=False, + ) + + def test_keep_others(self): + """User stays in its non-K-Fêt groups.""" + user = self.user + + # add user to a non-K-Fêt group + user.groups.add(self.other_group) + + # add user to some K-Fêt groups through UserGroupForm + data = { + 'groups': [group.pk for group in self.kfet_groups], + } + form = UserGroupForm(data, instance=user) + + form.is_valid() + form.save() + self.assertQuerysetEqual( + user.groups.all(), + [repr(g) for g in [self.other_group] + self.kfet_groups], + ordered=False, + ) + + +class KFetGenericUserTests(TestCase): + + def test_exists(self): + """ + The account is set up when app is ready, so it should exist. + """ + generic = Account.objects.get_generic() + self.assertEqual(generic.trigramme, KFET_GENERIC_TRIGRAMME) + self.assertEqual(generic.user.username, KFET_GENERIC_USERNAME) + self.assertEqual(get_kfet_generic_user(), generic.user) + + +## +# Backends +## + +class AccountBackendTests(TestCase): + + def setUp(self): + self.request = RequestFactory().get('/') + + def test_valid(self): + acc = Account(trigramme='000') + acc.change_pwd('valid') + acc.save({'username': 'user'}) + + auth = AccountBackend().authenticate( + self.request, kfet_password='valid') + + self.assertEqual(auth, acc.user) + + def test_invalid(self): + auth = AccountBackend().authenticate( + self.request, kfet_password='invalid') + self.assertIsNone(auth) + + +class GenericBackendTests(TestCase): + + def setUp(self): + self.request = RequestFactory().get('/') + + def test_valid(self): + token = GenericTeamToken.objects.create_token() + + auth = GenericBackend().authenticate( + self.request, kfet_token=token.token) + + self.assertEqual(auth, get_kfet_generic_user()) + self.assertEqual(GenericTeamToken.objects.all().count(), 0) + + def test_invalid(self): + auth = GenericBackend().authenticate( + self.request, kfet_token='invalid') + self.assertIsNone(auth) + + +## +# Views +## + +class GenericLoginViewTests(TestCase): + + def setUp(self): + patcher_messages = mock.patch('gestioncof.signals.messages') + patcher_messages.start() + self.addCleanup(patcher_messages.stop) + + user_acc = Account(trigramme='000') + user_acc.save({'username': 'user'}) + self.user = user_acc.user + self.user.set_password('user') + self.user.save() + + team_acc = Account(trigramme='100') + team_acc.save({'username': 'team'}) + self.team = team_acc.user + self.team.set_password('team') + self.team.save() + self.team.user_permissions.add( + Permission.objects.get( + content_type__app_label='kfet', codename='is_team'), + ) + + self.url = reverse('kfet.login.generic') + self.generic_user = get_kfet_generic_user() + + def test_url(self): + self.assertEqual(self.url, '/k-fet/login/generic') + + def test_notoken_get(self): + """ + Send confirmation for user to emit POST request, instead of GET. + """ + self.client.login(username='team', password='team') + + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertTemplateUsed(r, 'kfet/confirm_form.html') + + def test_notoken_post(self): + """ + POST request without token in COOKIES sets a token and redirects to + logout url. + """ + self.client.login(username='team', password='team') + + r = self.client.post(self.url) + + self.assertRedirects( + r, '/logout?next={}'.format(self.url), + fetch_redirect_response=False, + ) + + def test_notoken_not_team(self): + """ + Logged in user must be a team user to initiate login as generic user. + """ + self.client.login(username='user', password='user') + + # With GET. + r = self.client.get(self.url) + self.assertRedirects( + r, '/login?next={}'.format(self.url), + fetch_redirect_response=False, + ) + + # Also with POST. + r = self.client.post(self.url) + self.assertRedirects( + r, '/login?next={}'.format(self.url), + fetch_redirect_response=False, + ) + + def _set_signed_cookie(self, client, key, value): + signed_value = signing.get_cookie_signer(salt=key).sign(value) + client.cookies.load({key: signed_value}) + + def _is_cookie_deleted(self, client, key): + try: + self.assertNotIn(key, client.cookies) + except AssertionError: + try: + cookie = client.cookies[key] + # It also can be emptied. + self.assertEqual(cookie.value, '') + self.assertEqual( + cookie['expires'], 'Thu, 01-Jan-1970 00:00:00 GMT') + self.assertEqual(cookie['max-age'], 0) + except AssertionError: + raise AssertionError("The cookie '%s' still exists." % key) + + def test_withtoken_valid(self): + """ + The kfet generic user is logged in. + """ + token = GenericTeamToken.objects.create(token='valid') + self._set_signed_cookie( + self.client, GenericLoginView.TOKEN_COOKIE_NAME, 'valid') + + r = self.client.get(self.url) + + self.assertRedirects(r, reverse('kfet.kpsul')) + self.assertEqual(r.wsgi_request.user, self.generic_user) + self._is_cookie_deleted( + self.client, GenericLoginView.TOKEN_COOKIE_NAME) + with self.assertRaises(GenericTeamToken.DoesNotExist): + token.refresh_from_db() + + def test_withtoken_invalid(self): + """ + If token is invalid, delete it and try again. + """ + self._set_signed_cookie( + self.client, GenericLoginView.TOKEN_COOKIE_NAME, 'invalid') + + r = self.client.get(self.url) + + self.assertRedirects(r, self.url, fetch_redirect_response=False) + self.assertEqual(r.wsgi_request.user, AnonymousUser()) + self._is_cookie_deleted( + self.client, GenericLoginView.TOKEN_COOKIE_NAME) + + def test_flow_ok(self): + """ + A team user is logged in as the kfet generic user. + """ + self.client.login(username='team', password='team') + next_url = '/k-fet/' + + r = self.client.post( + '{}?next={}'.format(self.url, next_url), follow=True) + + self.assertEqual(r.wsgi_request.user, self.generic_user) + self.assertEqual(r.wsgi_request.path, '/k-fet/') + + +## +# Temporary authentication +# +# Includes: +# - TemporaryAuthMiddleware +# - temporary_auth context processor +## + +class TemporaryAuthTests(TestCase): + + def setUp(self): + patcher_messages = mock.patch('gestioncof.signals.messages') + patcher_messages.start() + self.addCleanup(patcher_messages.stop) + + self.factory = RequestFactory() + + self.middleware = TemporaryAuthMiddleware(mock.Mock()) + + user1_acc = Account(trigramme='000') + user1_acc.change_pwd('kfet_user1') + user1_acc.save({'username': 'user1'}) + self.user1 = user1_acc.user + self.user1.set_password('user1') + self.user1.save() + + user2_acc = Account(trigramme='100') + user2_acc.change_pwd('kfet_user2') + user2_acc.save({'username': 'user2'}) + self.user2 = user2_acc.user + self.user2.set_password('user2') + self.user2.save() + + self.perm = Permission.objects.get( + content_type__app_label='kfet', codename='is_team') + self.user2.user_permissions.add(self.perm) + + def test_middleware_header(self): + """ + A user can be authenticated if ``HTTP_KFETPASSWORD`` header of a + request contains a valid kfet password. + """ + request = self.factory.get('/', HTTP_KFETPASSWORD='kfet_user2') + request.user = self.user1 + + self.middleware(request) + + self.assertEqual(request.user, self.user2) + self.assertEqual(request.real_user, self.user1) + + def test_middleware_post(self): + """ + A user can be authenticated if ``KFETPASSWORD`` of POST data contains + a valid kfet password. + """ + request = self.factory.post('/', {'KFETPASSWORD': 'kfet_user2'}) + request.user = self.user1 + + self.middleware(request) + + self.assertEqual(request.user, self.user2) + self.assertEqual(request.real_user, self.user1) + + def test_middleware_invalid(self): + """ + The given password must be a password of an Account. + """ + request = self.factory.post('/', {'KFETPASSWORD': 'invalid'}) + request.user = self.user1 + + self.middleware(request) + + self.assertEqual(request.user, self.user1) + self.assertFalse(hasattr(request, 'real_user')) + + def test_context_processor(self): + """ + Context variables give the real authenticated user and his permissions. + """ + self.client.login(username='user1', password='user1') + + r = self.client.get('/k-fet/accounts/', HTTP_KFETPASSWORD='kfet_user2') + + self.assertEqual(r.context['user'], self.user1) + self.assertNotIn('kfet.is_team', r.context['perms']) + + def test_auth_not_persistent(self): + """ + The authentication is temporary, i.e. for one request. + """ + self.client.login(username='user1', password='user1') + + r1 = self.client.get( + '/k-fet/accounts/', HTTP_KFETPASSWORD='kfet_user2') + self.assertEqual(r1.wsgi_request.user, self.user2) + + r2 = self.client.get('/k-fet/accounts/') + self.assertEqual(r2.wsgi_request.user, self.user1) diff --git a/kfet/auth/utils.py b/kfet/auth/utils.py new file mode 100644 index 00000000..0edc555d --- /dev/null +++ b/kfet/auth/utils.py @@ -0,0 +1,34 @@ +import hashlib + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission + +from kfet.models import Account + +User = get_user_model() + + +def get_kfet_generic_user(): + """ + Return the user related to the kfet generic account. + """ + return Account.objects.get_generic().user + + +def setup_kfet_generic_user(**kwargs): + """ + First steps of setup of the kfet generic user are done in a migration, as + it is more robust against database schema changes. + Following steps cannot be done from migration. + """ + generic = get_kfet_generic_user() + generic.user_permissions.add( + Permission.objects.get( + content_type__app_label='kfet', + codename='is_team', + ) + ) + + +def hash_password(password): + return hashlib.sha256(password.encode('utf-8')).hexdigest() diff --git a/kfet/auth/views.py b/kfet/auth/views.py new file mode 100644 index 00000000..7b9f4099 --- /dev/null +++ b/kfet/auth/views.py @@ -0,0 +1,136 @@ +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.contrib.auth import authenticate, login +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.models import Group, User +from django.contrib.auth.views import redirect_to_login +from django.core.urlresolvers import reverse, reverse_lazy +from django.db.models import Prefetch +from django.http import QueryDict +from django.shortcuts import redirect, render +from django.utils.decorators import method_decorator +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import View +from django.views.decorators.http import require_http_methods +from django.views.generic.edit import CreateView, UpdateView + +from .forms import GroupForm +from .models import GenericTeamToken + + +class GenericLoginView(View): + """ + View to authenticate as kfet generic user. + + It is a 2-step view. First, issue a token if user is a team member and send + him to the logout view (for proper disconnect) with callback url to here. + Then authenticate the token to log in as the kfet generic user. + + Token is stored in COOKIES to avoid share it with the authentication + provider, which can be external. Session is unusable as it will be cleared + on logout. + """ + TOKEN_COOKIE_NAME = 'kfettoken' + + @method_decorator(require_http_methods(['GET', 'POST'])) + def dispatch(self, request, *args, **kwargs): + token = request.get_signed_cookie(self.TOKEN_COOKIE_NAME, None) + if not token: + if not request.user.has_perm('kfet.is_team'): + return redirect_to_login(request.get_full_path()) + + if request.method == 'POST': + # Step 1: set token and logout user. + return self.prepare_auth() + else: + # GET request should not change server/client states. Send a + # confirmation template to emit a POST request. + return render(request, 'kfet/confirm_form.html', { + 'title': _("Ouvrir une session partagée"), + 'text': _( + "Êtes-vous sûr·e de vouloir ouvrir une session " + "partagée ?" + ), + }) + else: + # Step 2: validate token. + return self.validate_auth(token) + + def prepare_auth(self): + # Issue token. + token = GenericTeamToken.objects.create_token() + + # Prepare callback of logout. + here_url = reverse(login_generic) + if 'next' in self.request.GET: + # Keep given next page. + here_qd = QueryDict(mutable=True) + here_qd['next'] = self.request.GET['next'] + here_url += '?{}'.format(here_qd.urlencode()) + + logout_url = reverse('cof-logout') + logout_qd = QueryDict(mutable=True) + logout_qd['next'] = here_url + logout_url += '?{}'.format(logout_qd.urlencode(safe='/')) + + resp = redirect(logout_url) + resp.set_signed_cookie( + self.TOKEN_COOKIE_NAME, token.token, httponly=True) + return resp + + def validate_auth(self, token): + # Authenticate with GenericBackend. + user = authenticate(request=self.request, kfet_token=token) + + if user: + # Log in generic user. + login(self.request, user) + messages.success(self.request, _( + "K-Fêt — Ouverture d'une session partagée." + )) + resp = redirect(self.get_next_url()) + else: + # Try again. + resp = redirect(self.request.get_full_path()) + + # Prevents blocking due to an invalid COOKIE. + resp.delete_cookie(self.TOKEN_COOKIE_NAME) + return resp + + def get_next_url(self): + return self.request.GET.get('next', reverse('kfet.kpsul')) + + +login_generic = GenericLoginView.as_view() + + +@permission_required('kfet.manage_perms') +def account_group(request): + user_pre = Prefetch( + 'user_set', + queryset=User.objects.select_related('profile__account_kfet'), + ) + groups = ( + Group.objects + .filter(name__icontains='K-Fêt') + .prefetch_related('permissions', user_pre) + ) + return render(request, 'kfet/account_group.html', { + 'groups': groups, + }) + + +class AccountGroupCreate(SuccessMessageMixin, CreateView): + model = Group + template_name = 'kfet/account_group_form.html' + form_class = GroupForm + success_message = 'Nouveau groupe : %(name)s' + success_url = reverse_lazy('kfet.account.group') + + +class AccountGroupUpdate(SuccessMessageMixin, UpdateView): + queryset = Group.objects.filter(name__icontains='K-Fêt') + template_name = 'kfet/account_group_form.html' + form_class = GroupForm + success_message = 'Groupe modifié : %(name)s' + success_url = reverse_lazy('kfet.account.group') diff --git a/kfet/autocomplete.py b/kfet/autocomplete.py index 09057d4a..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 @@ -76,7 +74,7 @@ def account_create(request): queries['users_notcof'].values_list('username', flat=True)) # Fetching data from the SPI - if hasattr(settings, 'LDAP_SERVER_URL'): + if getattr(settings, 'LDAP_SERVER_URL', None): # Fetching ldap_query = '(&{:s})'.format(''.join( '(|(cn=*{bit:s}*)(uid=*{bit:s}*))'.format(bit=word) @@ -106,6 +104,7 @@ def account_create(request): return render(request, "kfet/account_create_autocomplete.html", data) +@teamkfet_required def account_search(request): if "q" not in request.GET: raise Http404 diff --git a/kfet/backends.py b/kfet/backends.py deleted file mode 100644 index fb9538d0..00000000 --- a/kfet/backends.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- - -import hashlib - -from django.contrib.auth.models import User, Permission -from gestioncof.models import CofProfile -from kfet.models import Account, GenericTeamToken - - -class KFetBackend(object): - def authenticate(self, request): - password = request.POST.get('KFETPASSWORD', '') - password = request.META.get('HTTP_KFETPASSWORD', password) - if not password: - return None - - try: - password_sha256 = ( - hashlib.sha256(password.encode('utf-8')) - .hexdigest() - ) - account = Account.objects.get(password=password_sha256) - return account.cofprofile.user - except Account.DoesNotExist: - return None - - -class GenericTeamBackend(object): - def authenticate(self, username=None, token=None): - valid_token = GenericTeamToken.objects.get(token=token) - if username == 'kfet_genericteam' and valid_token: - # Création du user s'il n'existe pas déjà - user, _ = User.objects.get_or_create(username='kfet_genericteam') - profile, _ = CofProfile.objects.get_or_create(user=user) - account, _ = Account.objects.get_or_create( - cofprofile=profile, - trigramme='GNR') - - # Ajoute la permission kfet.is_team à ce user - perm_is_team = Permission.objects.get(codename='is_team') - user.user_permissions.add(perm_is_team) - - return user - return None - - def get_user(self, user_id): - try: - return ( - User.objects - .select_related('profile__account_kfet') - .get(pk=user_id) - ) - except User.DoesNotExist: - return None diff --git a/kfet/cms/__init__.py b/kfet/cms/__init__.py new file mode 100644 index 00000000..0f6cab45 --- /dev/null +++ b/kfet/cms/__init__.py @@ -0,0 +1 @@ +default_app_config = 'kfet.cms.apps.KFetCMSAppConfig' diff --git a/kfet/cms/apps.py b/kfet/cms/apps.py new file mode 100644 index 00000000..1db0e043 --- /dev/null +++ b/kfet/cms/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class KFetCMSAppConfig(AppConfig): + name = 'kfet.cms' + label = 'kfetcms' + verbose_name = 'CMS K-Fêt' + + def ready(self): + from . import hooks diff --git a/kfet/cms/context_processors.py b/kfet/cms/context_processors.py new file mode 100644 index 00000000..34f175d1 --- /dev/null +++ b/kfet/cms/context_processors.py @@ -0,0 +1,20 @@ +from kfet.models import Article + + +def get_articles(request=None): + articles = ( + Article.objects + .filter(is_sold=True, hidden=False) + .select_related('category') + .order_by('category__name', 'name') + ) + pressions, others = [], [] + for article in articles: + if article.category.name == 'Pression': + pressions.append(article) + else: + others.append(article) + return { + 'pressions': pressions, + 'articles': others, + } diff --git a/kfet/cms/fixtures/kfet_wagtail_17_05.json b/kfet/cms/fixtures/kfet_wagtail_17_05.json new file mode 100644 index 00000000..66ac7040 --- /dev/null +++ b/kfet/cms/fixtures/kfet_wagtail_17_05.json @@ -0,0 +1,1456 @@ +[ +{ + "fields": { + "root_page": 9, + "port": 8000, + "site_name": "Global", + "hostname": "localhost", + "is_default_site": true + }, + "pk": 2, + "model": "wagtailcore.site" +}, +{ + "fields": { + "live": true, + "show_in_menus": false, + "numchild": 1, + "title": "Root", + "slug": "root", + "depth": 1, + "expire_at": null, + "search_description": "", + "content_type": [ + "wagtailcore", + "page" + ], + "owner": null, + "expired": false, + "first_published_at": null, + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001", + "latest_revision_created_at": null, + "url_path": "/", + "locked": false, + "seo_title": "" + }, + "pk": 1, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 5, + "title": "Bienvenue en K-Fêt", + "slug": "k-fet", + "depth": 3, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "000100010001", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/", + "locked": false, + "seo_title": "Accueil" + }, + "pk": 3, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "Mode d'emploi", + "slug": "mode-demploi", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010001", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/mode-demploi/", + "locked": false, + "seo_title": "" + }, + "pk": 4, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "L'\u00e9quipe", + "slug": "equipe", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010002", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/equipe/", + "locked": false, + "seo_title": "" + }, + "pk": 5, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "La carte", + "slug": "carte", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010003", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/carte/", + "locked": false, + "seo_title": "" + }, + "pk": 6, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "Les soir\u00e9es", + "slug": "soirees", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010004", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/soirees/", + "locked": false, + "seo_title": "" + }, + "pk": 7, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "Le flipper", + "slug": "flipper", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010005", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/flipper/", + "locked": false, + "seo_title": "" + }, + "pk": 8, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 1, + "title": "Global", + "slug": "global", + "depth": 2, + "expire_at": null, + "search_description": "", + "content_type": [ + "wagtailcore", + "page" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "00010001", + "latest_revision_created_at": null, + "url_path": "/global/", + "locked": false, + "seo_title": "" + }, + "pk": 9, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": false, + "numchild": 0, + "title": "Mentions l\u00e9gales", + "slug": "mentions-legales", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010006", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/mentions-legales/", + "locked": false, + "seo_title": "" + }, + "pk": 10, + "model": "wagtailcore.page" +}, +{ + "fields": { + "name": "Root", + "numchild": 1, + "path": "0001", + "depth": 1 + }, + "pk": 1, + "model": "wagtailcore.collection" +}, +{ + "fields": { + "name": "K-F\u00eat", + "numchild": 0, + "path": "00010001", + "depth": 2 + }, + "pk": 2, + "model": "wagtailcore.collection" +}, +{ + "pk": 1, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Hugo", + "nick_name": "", + "photo": 3, + "last_name": "Manet" + } +}, +{ + "pk": 2, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Lisa", + "nick_name": "", + "photo": 4, + "last_name": "Gourdon" + } +}, +{ + "pk": 3, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Pierre", + "nick_name": "", + "photo": 5, + "last_name": "Quesselaire" + } +}, +{ + "pk": 4, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Thibault", + "nick_name": "", + "photo": 6, + "last_name": "Scoquard" + } +}, +{ + "pk": 5, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Arnaud", + "nick_name": "", + "photo": 7, + "last_name": "Fanthomme" + } +}, +{ + "pk": 6, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Vincent", + "nick_name": "", + "photo": 8, + "last_name": "Balerdi" + } +}, +{ + "pk": 7, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Nathana\u00ebl", + "nick_name": "", + "photo": 9, + "last_name": "Willaime" + } +}, +{ + "pk": 8, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "\u00c9lisabeth", + "nick_name": "", + "photo": 10, + "last_name": "Miller" + } +}, +{ + "pk": 9, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Arthur", + "nick_name": "B2O", + "photo": 11, + "last_name": "Lesage" + } +}, +{ + "pk": 10, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Sarah", + "nick_name": "", + "photo": 12, + "last_name": "Asset" + } +}, +{ + "pk": 11, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Alexandre", + "nick_name": "", + "photo": 13, + "last_name": "Legrand" + } +}, +{ + "pk": 12, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "\u00c9tienne", + "nick_name": "", + "photo": 14, + "last_name": "Baudel" + } +}, +{ + "pk": 13, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Marine", + "nick_name": "", + "photo": 15, + "last_name": "Snape" + } +}, +{ + "pk": 14, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Anatole", + "nick_name": "", + "photo": 16, + "last_name": "Gosset" + } +}, +{ + "pk": 15, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Jacko", + "nick_name": "", + "photo": 17, + "last_name": "Rastikian" + } +}, +{ + "pk": 16, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Alexandre", + "nick_name": "", + "photo": 18, + "last_name": "Jannaud" + } +}, +{ + "pk": 17, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Aur\u00e9lien", + "nick_name": "", + "photo": 19, + "last_name": "Delobelle" + } +}, +{ + "pk": 18, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Sylvain", + "nick_name": "", + "photo": 20, + "last_name": "Douteau" + } +}, +{ + "pk": 19, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Rapha\u00ebl", + "nick_name": "", + "photo": 21, + "last_name": "Lescanne" + } +}, +{ + "pk": 20, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Romain", + "nick_name": "", + "photo": 22, + "last_name": "Gourvil" + } +}, +{ + "pk": 21, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Marie", + "nick_name": "", + "photo": 23, + "last_name": "Labeye" + } +}, +{ + "pk": 22, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Oscar", + "nick_name": "", + "photo": 24, + "last_name": "Blumberg" + } +}, +{ + "pk": 23, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Za\u00efd", + "nick_name": "", + "photo": 25, + "last_name": "Allybokus" + } +}, +{ + "pk": 24, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Damien", + "nick_name": "", + "photo": 26, + "last_name": "Garreau" + } +}, +{ + "pk": 25, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Andr\u00e9a", + "nick_name": "", + "photo": 27, + "last_name": "Londonez-Lopez" + } +}, +{ + "pk": 26, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Tristan", + "nick_name": "", + "photo": 28, + "last_name": "Roussel" + } +}, +{ + "pk": 27, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Guillaume", + "nick_name": "", + "photo": 29, + "last_name": "Vernade" + } +}, +{ + "pk": 28, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Lucas", + "nick_name": "", + "photo": 30, + "last_name": "Mercier" + } +}, +{ + "pk": 29, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Fran\u00e7ois", + "nick_name": "M\u00e9talleux", + "photo": 31, + "last_name": "Maillot" + } +}, +{ + "pk": 30, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Fabrice", + "nick_name": "", + "photo": 32, + "last_name": "Catoire" + } +}, +{ + "pk": 3, + "model": "kfetcms.kfetpage", + "fields": { + "main_size": "", + "content": "[{\"value\": \"

    La K-F\\u00eat, c'est quoi ?

    \\n\\n

    \\n Eh bien la K-F\\u00eat, c'est le bar des \\u00e9l\\u00e8ves de l'\\u00c9cole normale\\n sup\\u00e9rieure. Elle est situ\\u00e9e dans les locaux de l'\\u00c9cole, au pied de\\n l'escalier C (plan). On y trouve \\u00e0 boire, bien s\\u00fbr,\\n des bi\\u00e8res en nombre pl\\u00e9thorique mais aussi caf\\u00e9s, th\\u00e9s, softs et de quoi\\n grignoter. Ah oui un point important, on ne va pas \\u00e0 la K-F\\u00eat, on va EN K-F\\u00eat.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Mais on n'y fait que boire et manger ?

    \\n\\n

    \\n Que nenni, \\u00f4 jeune et innocent conscrit-e ! La K-F\\u00eat n'est pas un bouge\\n sordide o\\u00f9 des piliers de bar passent leurs journ\\u00e9es \\u00e0 picoler. Enfin pas\\n uniquement. C'est aussi un lieu de divertissement avec\\n son flipper (la mythique, la seule, l'unique,\\n la g\\u00e9niale Amazon Hunt), son baby-foot et le lieu d'\\u00e9lection des \\nbridgeur-se-s, du club jeux, des joueur-se-s de poker voire des quelques\\n irr\\u00e9ductibles du boulot qui y viennent bosser en profitant du point \\nd'acc\\u00e8s wifi. \\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Ah \\u00e7a a l'air bien mais... qui s'en occupe ? C'est ouvert quand ?

    \\n\\n

    \\n L'\\u00e9quipe d'\\u00e9l\\u00e8ves motiv\\u00e9-e-s qui s'occupent de la K-F\\u00eat s'appelle, en toute logique, l'\\u00e9quipe K-F\\u00eat.\\n Elle est men\\u00e9e par un-e leader charismatique et bien-aim\\u00e9-e, \\naccompagn\\u00e9-e de ses troupes de fid\\u00e8les, les K-F\\u00eat wo-men, boys et girls.\\n Le local de la K-F\\u00eat n'est ouvert que si un-e K-F\\u00eat wo-man est \\npr\\u00e9sente. \\u00c0 savoir la plupart du temps entre 12h et 3h du matin.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Et je peux y faire ce que je veux ?

    \\n\\n

    \\n Oui et non. Nous ne sommes pas ta grand-m\\u00e8re et nous n'allons\\n certainement pas t'emp\\u00eacher de faire la f\\u00eate, ni de d\\u00e9guster des pintes\\n jusqu'au petit p\\u00f4t. Par contre nous attendons de toi que tu ne sois pas\\n un-e gros-se con-ne. A priori pas de raison de le croire, mais jette tout de m\\u00eame\\n un \\u0153il sur le mode d'emploi de la K-F\\u00eat, \\u00e7a\\n pourrait t'\\u00e9viter de perdre un genoux ou deux...\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    J'adore la K-F\\u00eat, j'aimerais y organiser une soir\\u00e9e, c'est possible ?

    \\n\\n

    \\n Bien s\\u00fbr\\u00a0! Pour cela commence par lire ce petit\\n guide histoire de savoir dans quoi tu t'engages puis contacte ton-ta chef-fe K-F\\u00eat ador\\u00e9-e pour v\\u00e9rifier que la date de ta\\n soir\\u00e9e n'est pas d\\u00e9j\\u00e0 prise par une autre f\\u00eate et obtenir son\\n accord.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    J'ai une question \\u00e0 vous poser. O\\u00f9 puis-je vous contacter ?

    \\n\\n

    \\n Commence d\\u00e9j\\u00e0 par jeter un oeil sur le mode\\n d'emploi de la K-F\\u00eat. Si la r\\u00e9ponse \\u00e0 tes interrogations ne s'y\\n trouve pas, rien n'est perdu. En effet le service informatique de \\nl'\\u00c9cole, dans sa grande mansu\\u00e9tude, a mis \\u00e0 disposition de l'\\u00e9quipe \\nK-F\\u00eat une adresse e-mail, k-fet@ens.fr. Mais sinon, passe en K-F\\u00eat, il y aura sans doute un K-F\\u00eat wo-man qui saura r\\u00e9pondre \\u00e0 ta question.\\n

    \", \"type\": \"rich\"}]", + "layout": "kfet/base_col_mult.html", + "no_header": false, + "col_count": "" + } +}, +{ + "pk": 4, + "model": "kfetcms.kfetpage", + "fields": { + "main_size": "", + "content": "[{\"value\": \"

    Article 0 : La K-F\\u00eat n'existe pas.

    \", \"type\": \"rich\"}, {\"value\": \"

    La K-F\\u00eat, c'est magique, comment \\u00e7a marche ?

    La K-F\\u00eat n'a \\nrien de magique, il n'y a pas de petits d\\u00e9mons qui font le m\\u00e9nage, pas \\nplus que d'arbustes g\\u00e9n\\u00e9tiquement modifi\\u00e9s aux OGM sur lesquels poussent\\n les bouteilles de bi\\u00e8res. La K-F\\u00eat c'est avant tout une \\u00e9quipe qui sacrifie une partie de son temps libre pour que tout se passe pour le mieux.

    \", \"type\": \"rich\"}, {\"value\": \"

    Que puis-je faire pour vous aider un peu ?

    D\\u00e9j\\u00e0 ne pas poser \\nde probl\\u00e8mes, c'est \\u00e0 dire ne pas r\\u00e9veiller tout l'internat en sortant, \\nessayer de ne pas finir dans un \\u00e9tat trop avanc\\u00e9 d'alcoolisation, etc...\\n Mine de rien \\u00e7a nous \\u00e9viterait quelques probl\\u00e8mes.

    Ensuite, comme\\n tu le sais s\\u00fbrement les bi\\u00e8res sont consign\\u00e9es, il est donc pr\\u00e9f\\u00e9rable \\npour nous que tu n'embarques pas les bouteilles en souvenir dans ta \\nthurne. Mieux, tu peux nous faire gagner du temps de rangement en les \\nramenant au bar en partant. Et encore mieux, tu peux jeter tes d\\u00e9chets \\n(gobelets, boite de pringles, etc...). Si tu fais d\\u00e9j\\u00e0 tout \\u00e7a tu nous \\nsimplifieras grandement la vie.

    \", \"type\": \"rich\"}, {\"value\": \"

    Le syst\\u00e8me mon\\u00e9taire de la K-F\\u00eat

    En bon \\u00e9tat souverain et \\nind\\u00e9pendant, la K-F\\u00eat a sa propre monnaie : l'unit\\u00e9 K-F\\u00eat (UKF). Elle \\nvaut 10 centimes d'euro. La K-F\\u00eat ne battant pas monnaie, les UKF que tu\\n poss\\u00e8des sont not\\u00e9es sur ton compte, identifi\\u00e9 par un trigramme (une \\nsuite de trois caract\\u00e8res) et que tu peux recharger en liquide ou par \\nch\\u00e8que. Note que si tu y tiens vraiment, tu peux payer en liquide, mais \\nposs\\u00e9der un compte est bien plus pratique.

    \", \"type\": \"rich\"}, {\"value\": \"

    Comment commander \\u00e0 boire ou \\u00e0 manger ?

    Pour commander \\u00e0 boire ou \\u00e0 manger, il suffit de demander \\u00e0 un membre de l'\\u00e9quipe K-F\\u00eat.\\n Et \\u00e7a marche encore mieux si la demande est effectu\\u00e9e avec le sourire \\nau d\\u00e9but et un merci \\u00e0 la fin : l'\\u00e9quipe est constitu\\u00e9e de volontaires \\nb\\u00e9n\\u00e9voles, et mieux vaut ne pas les prendre pour des chiens. EN AUCUN \\nCAS on ne passe derri\\u00e8re le bar si on n'est pas membre de l'\\u00e9quipe K-F\\u00eat.

    \", \"type\": \"rich\"}, {\"value\": \"

    Puis-je fumer en K-F\\u00eat ?

    Non ! Imagine-toi les jours de \\nsoir\\u00e9es, la K-F\\u00eat remplie et tout le monde qui fume... On finirait tous \\navec des poumons aussi crades que le sol de la K-F\\u00eat. Ce serait quand \\nm\\u00eame dommage pour la recherche fran\\u00e7aise qu'on cr\\u00e8ve tous avant 30 ans, \\nnon ?

    Par contre tu peux fumer dehors, il y a m\\u00eame des cendriers \\njuste pour toi, par contre tu remarqueras que les chambres de l'internat\\n se trouvent juste au dessus de toi. T\\u00e2che donc de ne pas faire trop de \\nbruit.

    \", \"type\": \"rich\"}, {\"value\": \"

    Et amener ma propre bouteille ?

    D\\u00e9j\\u00e0 c'est apporter, enfin en\\n tout cas avant de la boire. Ensuite la K-F\\u00eat est un lieu de \\nconvivialit\\u00e9 o\\u00f9 les bi\\u00e8res te sont vendues au prix co\\u00fbtant,\\n franchement ce serait pas fair-play de te la jouer solo. Alors \\n\\u00e9videment il y a des exceptions, par exemple si tu reviens de Belgique \\net que tu veux faire go\\u00fbter de la Wesvleteren \\u00e0 tes amis de l'\\u00e9quipe K-F\\u00eat,\\n ou si tu veux organiser une d\\u00e9gustation de vins avec la charcuterie qui\\n va bien. Tu comprendras qu'un pack de Kro c'est quand m\\u00eame pas la m\\u00eame \\nclasse...

    \", \"type\": \"rich\"}, {\"value\": \"

    Je peux passer ma musique ?

    Bien s\\u00fbr, nous sommes tr\\u00e8s loin \\nde penser tout conna\\u00eetre en mati\\u00e8re de musique. Mais comme nous sommes \\nentre gens civilis\\u00e9s, et que je te rappelle que tu n'as pas le droit de \\npasser derri\\u00e8re le bar, il convient de demander \\u00e0 un-e membre de l'\\u00e9quipe K-F\\u00eat\\n afin qu'ille t'indique qui est \\u00e0 l'origine de ces chansons que tu \\nn'appr\\u00e9cies apparemment pas. Apr\\u00e8s avoir obtenu son accord tu peux \\ndemander \\u00e0 quelqu'un de mettre ta playlist, qui peut-\\u00eatre sur un lecteur\\n mp3, sur Deezer ou juste sur l'ordi, mais dans ce dernier cas ce sera plus dur puisque tu n'y aura pas acc\\u00e8s directement.

    Le plus simple pour toi (et pour nous) est donc de pr\\u00e9voir des playlists sur Deezer\\n d'avance et de nous les proposer. Par contre, sois gentil-le, n'insiste\\n pas si nous ne voulons pas de ta musique traditionnelle hongroise. Par \\nailleurs, si un trop grand nombre de personnes nous demande de passer de\\n la musique, l'\\u00e9quipe K-F\\u00eat peut ne pas acc\\u00e9der \\u00e0 ta requ\\u00eate.

    \", \"type\": \"rich\"}, {\"value\": \"

    Comment organiser une soir\\u00e9e en K-F\\u00eat ?

    Tout membre du COF \\npeut organiser une soir\\u00e9e en K-F\\u00eat \\u00e0 la condition qu'elle soit publique \\net annonc\\u00e9e une semaine \\u00e0 l'avance par des affiches dans l'\\u00e9cole et un \\nmot dans le BOcal. Il faut bien sur aussi l'accord du COF qui s'occupe \\nde voir si \\u00e7a ne pose pas de probl\\u00e8me \\u00e0 l'admin, celui de la K-F\\u00eat team \\npour qu'il y ait des K-F\\u00eat wo-men pour servir et s\\u00fbrement du BOUM pour \\nqu'il s'occupe de la musique. Nous t'avons tout r\\u00e9sum\\u00e9 ici\\n ; merci qui ? Une fois que tu as accompli ces formalit\\u00e9s, il ne te \\nreste plus qu'\\u00e0 imprimer et coller des affiches pour que ta soir\\u00e9e soit \\nun succ\\u00e8s !

    \", \"type\": \"rich\"}, {\"value\": \"

    D'autres remarques ?

    Des tonnes, en voici quelques unes :

    • Ce n'est pas caf\\u00e8t, ni kfet, ni caf\\u00e9t\\u00e9ria, c'est K-F\\u00eat, avec les majuscules.
    • On dit \\\"en K-F\\u00eat\\\".
    • On ne passe pas derri\\u00e8re le bar,\\n je sais je l'ai d\\u00e9j\\u00e0 dit, mais \\u00e7a a du mal \\u00e0 rentrer. S'il n'y a \\npersonne pour servir c'est que les K-F\\u00eat people sont soit occup\\u00e9-e-s \\nquelque chose d'important en arri\\u00e8re-K-F\\u00eat, soit sont pos\\u00e9-e-s dans le \\ncanap\\u00e9 \\u00e0 c\\u00f4t\\u00e9 du bar, soit sont en train de jouer \\u00e0 l'Amazon. Demande-leur, ou prends ton mal en patience.
    • La K-F\\u00eat n'est pas une porcherie, tu n'es pas oblig\\u00e9-e de laisser tout ton bordel quand tu pars.
    • Merci d'avoir lu jusque l\\u00e0.
    \", \"type\": \"rich\"}]", + "layout": "kfet/base_col_mult.html", + "no_header": false, + "col_count": "" + } +}, +{ + "pk": 5, + "model": "kfetcms.kfetpage", + "fields": { + "main_size": "", + "content": "[{\"value\": [{\"value\": \"

    Les ancien-ne-s Chef-fe-s K-F\\u00eat

    Les ancien-ne-s Chef-fe-s K-F\\u00eat doivent bien \\u00eatre pr\\u00e9sent\\u00e9-e-s avant \\nl'\\u00e9quipe actuelle. C'est gr\\u00e2ce \\u00e0 elleux qu'elle tourne encore, gr\\u00e2ce \\u00e0 \\nelleux qu'elle a bien tourn\\u00e9, et puis, de pr\\u00e8s comme de loin, illes \\nveillent encore sur nous. Ce sont les diff\\u00e9rentes facettes de la K-F\\u00eat \\nhistorique, bien que d'un certain point de vue, illes se ressemblent \\ntou-te-s : les Chef-fe-s K-F\\u00eat sont une dynastie, ils n'ont pas \\u00e9t\\u00e9 \\nChef-fe-s apr\\u00e8s avoir prouv\\u00e9 quoi que ce soit, illes l'ont \\u00e9t\\u00e9 parce que\\n ce r\\u00f4le leur revenait de droit. On na\\u00eet Chef-fe K-F\\u00eat, on ne le devient\\n pas. Et on le reste toujours, dans l'\\u00e2me.

    \", \"type\": \"rich\"}, {\"value\": {\"members\": [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], \"show_only\": 12}, \"type\": \"group_team\"}], \"type\": \"group\"}, {\"value\": [{\"value\": \"

    Le chef

    Le-la chef-fe K-F\\u00eat, celui-celle qui a le droit de vie et de mort sur les \\u00e2mes \\u00e9gar\\u00e9es qui fr\\u00e9quentent la K-F\\u00eat.

    \", \"type\": \"rich\"}, {\"value\": {\"members\": [1, 2], \"show_only\": null}, \"type\": \"group_team\"}], \"type\": \"group\"}, {\"value\": [{\"value\": \"

    Les K-F\\u00eat Wo-Men

    Les K-F\\u00eat wo-men poss\\u00e8dent les cl\\u00e9s de la K-F\\u00eat. Ce sont elleux qui peuvent d\\u00e9cider ou non d'ouvrir la K-F\\u00eat.

    \", \"type\": \"rich\"}, {\"value\": {\"members\": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12], \"show_only\": null}, \"type\": \"group_team\"}], \"type\": \"group\"}, {\"value\": [{\"value\": \"

    Les Vieux-illes

    Les vieux-illes sont d'ancien-ne-s K-F\\u00eat wo-men qui ne viennent plus \\naussi souvent qu'avant, illes servent toujours, mais n'ont en g\\u00e9n\\u00e9ral \\nplus les cl\\u00e9s. Illes existent n\\u00e9anmoins, et on les garde (pour \\ncertain-e-s) parce qu'au fond, on les aime quand m\\u00eame, et qu'en plus, \\nilles en savent plus que n'importe qui sur la K-F\\u00eat.

    \", \"type\": \"rich\"}], \"type\": \"group\"}, {\"value\": \"

    Les K-F\\u00eat boys et girls

    \\n\\n

    Les K-F\\u00eat boys and girls font de main d'\\u0153uvre bon march\\u00e9 pour la \\nK-F\\u00eat, illes peuvent passer derri\\u00e8re le bar, prendre vos commandes et \\nrecharger votre compte si par malheur il est \\u00e0 sec. La liste de \\ncelleux-ci est trop longue pour tou-te-s les citer, pour les reconna\\u00eetre\\n regarde les gens qui passent derri\\u00e8re le bar tout en conservant leur \\nint\\u00e9grit\\u00e9 physique.

    \", \"type\": \"rich\"}, {\"value\": \"

    Comment devient-on K-F\\u00eat people ?

    Grande question que tout le monde se pose un jour ou l'autre. Pour \\nacc\\u00e9der au titre prestigieux de K-F\\u00eat boy-girl, il est n\\u00e9cessaire mais \\npas suffisant d'\\u00eatre assid\\u00fbment pr\\u00e9sent-e en K-F\\u00eat, et d'\\u00eatre pr\\u00eat-e \\u00e0 \\ntrimer pour elle. Si tu es souvent en K-F\\u00eat, que tu es sympathique et \\nmotiv\\u00e9-e, et surtout en fin de compte si le-la chef-fe le veut bien, tu \\npourras devenir K-F\\u00eat boy-girl et passer derri\\u00e8re le bar pour servir. \\nEnsuite, si tu es motiv\\u00e9-e et efficace, ou simplement si t'es un-e pote \\ndu-de la chef-fe et qu'ille n'a aucun scrupule, tu pourras devenir K-F\\u00eat\\n wo-man et avoir la cl\\u00e9.

    Et comme la K-F\\u00eat c'est avant tout beaucoup d'emmerdes on a pas envie\\n de te forcer la main, on veut que cela vienne de toi. Donc si tu te \\nsens pr\\u00eat-e \\u00e0 participer \\u00e0 la vie mouvement\\u00e9e de la K-F\\u00eat fais-en part \\nau-\\u00e0 la chef-fe. Ille ne va pas te manger.

    \", \"type\": \"rich\"}]", + "layout": "kfet/base_col_mult.html", + "no_header": false, + "col_count": "" + } +}, +{ + "pk": 6, + "model": "kfetcms.kfetpage", + "fields": { + "main_size": "", + "content": "[{\"value\": \"

    Le service de la bi\\u00e8re est, historiquement, la mission et le sacerdoce \\ndu K-F\\u00eat people. Ille y est d\\u00e9vou\\u00e9-e corps et \\u00e2me, et accomplit sa t\\u00e2che\\n avec ardeur et passion. Voyons comment se d\\u00e9clinent les occasions \\nd'approcher du nirvana brassicole. Les prix donn\\u00e9s sont en UKF. Si tu \\nn'as pas compris, va voir par ici.

    \", \"type\": \"rich\"}, {\"value\": null, \"type\": \"carte\"}]", + "layout": "kfet/base_col_mult.html", + "no_header": false, + "col_count": "column-md-2 column-lg-3" + } +}, +{ + "pk": 7, + "model": "kfetcms.kfetpage", + "fields": { + "main_size": "", + "content": "[{\"value\": \"

    Tu veux organiser une soir\\u00e9e en K-F\\u00eat ? Pas de probl\\u00e8me !

    \", \"type\": \"rich\"}, {\"value\": \"

    Quand puis-je organiser une soir\\u00e9e ?

    \\n\\n

    \\n Tu peux organiser une soir\\u00e9e le jour que tu souhaites, \\u00e0 condition que la\\n date ne soit pas d\\u00e9j\\u00e0 prise par quelqu'un d'autre. Sache par contre que la\\n K-F\\u00eat ne te sera pas enti\\u00e8rement d\\u00e9di\\u00e9e et que les utilisateur-rice-s habituel-le-s\\n continueront de la fr\\u00e9quenter (et risquent fortement de squatter ta\\n soir\\u00e9e). Donc si tu veux un peu d'intimit\\u00e9 les soir\\u00e9es du week-end sont plus\\n conseill\\u00e9es (mais l'\\u00e9quipe risque de ne pas \\u00eatre pr\\u00e9sente), mais aussi\\n plus pris\\u00e9es, d\\u00e9p\\u00eache-toi de r\\u00e9server la tienne.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Quelles d\\u00e9marches dois-je effectuer ?

    \\n

    \\n D\\u00e9j\\u00e0 pr\\u00e9venir poliment l'\\u00e9quipe K-F\\u00eat, et\\n surtout le-la chef-fe pour v\\u00e9rifier que la date est encore libre, et qu'il y\\n aura au moins quelqu'un pour t'ouvrir la K-F\\u00eat. Ensuite, si ta soir\\u00e9e\\n n'est pas une simple bouffe qui finit avant minuit il faut pr\\u00e9venir les\\n vigiles via l'administration au moyen d'une demande d'autorisation de\\n soir\\u00e9e qui se trouve sur la section du p\\u00f4le Pr\\u00e9vention et S\\u00e9curit\\u00e9 sur l'intranet : demande d'autorisation de soir\\u00e9e.\\n \\u00c0 faire au moins une semaine avant ta soir\\u00e9e.\\n

    \\n

    \\n Si en plus tu as besoin que le BOUM s'occupe de la musique et/ou PLS des\\n lumi\\u00e8res c'est elleux qu'il faut contacter. Histoire de t'\\u00e9viter\\n d'avoir \\u00e0 chercher voici leur adresse\\u00a0: boum (at) ens (point) fr\\n et pls (at) ens (point) fr.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    C'est enfin le grand jour, je fais quoi ?

    \\n

    \\n D\\u00e9j\\u00e0 le m\\u00e9nage, oui je sais c'est chiant mais c'est le prix \\u00e0 payer pour\\n profiter du local. Demande \\u00e0 ce qu'un-e K-F\\u00eat wo-man t'ouvre et tu devrais avoir\\n \\u00e0 ta disposition tout ce qu'il faut pour faire briller la K-F\\u00eat (ou au moins on essaiera de\\n trouver ce qu'il faut). Fais par\\n contre attention aux bouteilles de bi\\u00e8re qui sont consign\\u00e9es, s'il n'y a\\n personne pour les ranger contente-toi de les mettre sur le bar, quelqu'un\\n s'en chargera plus tard. Les meubles peuvent \\u00eatre d\\u00e9plac\\u00e9s dans une salle\\n voisine si tu le souhaites, il faudra juste penser \\u00e0 les remettre en place.\\n

    \\n

    \\n Ensuite dans l'id\\u00e9al tu connais tous tes potes, donc en donner une liste \\u00e0\\n la loge permet d'\\u00e9viter quelques probl\\u00e8mes et quelques aller-retours.\\n Au-del\\u00e0 de 21h, les ext\\u00e9rieur-e-s ne peuvent rentrer qu'avec un-e Ulmien-ne ayant sa carte\\n sur lui-elle.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Je pourrai passer ma musique ?

    \\n\\n

    \\n Si le BOUM est pr\\u00e9sent, faut voir avec elleux : boum (at) ens (point) fr
    \\n Sinon, pr\\u00e9pare ta musique sur un lecteur mp3 ou une playlist\\n Deezer. Lors de la soir\\u00e9e,\\n demande \\u00e0 un-e K-F\\u00eat wo-man de passer ce que tu as pr\\u00e9par\\u00e9.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Et pour ce qui est de la nourriture, des boissons ?

    \\n

    \\n Tu peux apporter toute la nourriture que tu souhaites\\u00a0; pr\\u00e9vois assez\\n large, il y a beaucoup de K-F\\u00eat people \\u00e0 nourrir. Pour ce qui est de la\\n boisson, il faut te limiter aux boissons de cat\\u00e9gorie 2, c'est \\u00e0 dire\\n bi\\u00e8res, vins et boissons \\u00e0 base de vin, champagne et bien s\\u00fbr les boissons sans alcool.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Et pendant la soir\\u00e9e ?

    \\n

    \\n Ce soir c'est ton soir, il est donc bien s\\u00fbr \\u00e9vident que tu dois\\n rester pr\\u00e9sent-e et joignable du d\\u00e9but \\u00e0 la fin de la soir\\u00e9e. Id\\u00e9alement ce\\n doit aussi \\u00eatre le cas de tes \\\"Responsables ordre et discipline\\\". Vous ne serez pas\\n trop de deux ou trois pour r\\u00e9gler les probl\\u00e8mes qui pourraient survenir,\\n tes potes bourr\\u00e9-e-s, tes potes qui fument, tes potes qui font du bordel dans la cage d'escalier,\\n etc... Tous les probl\\u00e8mes qui pourraient survenir te seront imput\\u00e9s donc\\n pr\\u00e9viens-les, c'est tes potes apr\\u00e8s tout, non ?\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Apr\\u00e8s c'est bon ?

    \\n

    \\n Eh non, pas encore, apr\\u00e8s (ou le lendemain de) ta soir\\u00e9e il te faudra encore ranger,\\n faire le m\\u00e9nage et passer un coup de javel. Oui encore, mais bon, pense \\u00e0 toutes les fois o\\u00f9\\n c'est nous qui le faisons pour le bien de tou-te-s. Une fois n'est pas\\n coutume demande \\u00e0 un-e K-F\\u00eat wo-man de t'ouvrir et de te fournir tout le\\n mat\\u00e9riel dont tu pourrais avoir besoin, et l\\u00e0 o\\u00f9 c'est vraiment classe\\n c'est que tu peux m\\u00eame faire \\u00e7a en musique si tu le souhaites. N'oublie\\n pas non plus de rapporter les meubles que tu pourrais avoir sortis et que\\n les poubelles ne disparaissent pas toutes seules.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Une derni\\u00e8re remarque ?

    \\n\\n

    \\n Ouais, la K-F\\u00eat c'est pas chez m\\u00e9m\\u00e9, alors c'est peut-\\u00eatre ta soir\\u00e9e mais\\n si un-e membre de l'\\u00e9quipe K-F\\u00eat te dit quelque\\n chose (de baisser le son, de virer telle ou telle personne...) tu acceptes avec le sourire.\\n En particulier tu ne passes pas derri\\u00e8re le bar.\\n

    \", \"type\": \"rich\"}, {\"value\": \"

    Je ne parle pas bien fran\\u00e7ais, vous pourriez me faire un r\\u00e9sum\\u00e9 ?

    \\n

    \\n Organiser ta soir\\u00e9e c'est facile :\\n

    \\n
    • Envoie un mail \\u00e0 la K-F\\u00eat pour demander l'autorisation\\n : k-fet (at) ens (point) fr.
    • Lorsque c'est bon, remplis\\n le papier de l'admin, et\\n donne-le au-\\u00e0 la chef-fe K-F\\u00eat.
    • Pour la musique, l'alcool, contacte la K-F\\u00eat.
    • Le jour de la soir\\u00e9e, viens faire le m\\u00e9nage et donne une liste des\\n ext\\u00e9rieur-e-s \\u00e0 la loge.
    • Pendant la soir\\u00e9e, surveille tes invit\\u00e9s (pas trop de bruit \\u00e0\\n l'ext\\u00e9rieur de la K-F\\u00eat, pas de gens trop bourr\\u00e9s qui font des b\\u00eatises\\n avec les alarmes ou les sorties de secours...)
    • Apr\\u00e8s la soir\\u00e9e (le lendemain si tu veux) reviens faire le m\\u00e9nage.
    \\n

    \\n Voila, facile, non ?\\n

    \", \"type\": \"rich\"}]", + "layout": "kfet/base_col_mult.html", + "no_header": false, + "col_count": "" + } +}, +{ + "pk": 8, + "model": "kfetcms.kfetpage", + "fields": { + "main_size": "", + "content": "[{\"value\": \"

    Et le baby-foot

    \", \"type\": \"rich\"}, {\"value\": \"

    LE flipper

    \\n\\n\\n\\n

    \\n\\tIl existe en K-F\\u00eat une machine unique, inimitable, tout droit venue des\\n ann\\u00e9es folles et b\\u00e9nies o\\u00f9 les concepteurs de flippers connaissaient \\nencore leur m\\u00e9tier, o\\u00f9 les tilts \\u00e9taient m\\u00e9rit\\u00e9s et le jeu un art de \\nvivre. L'esth\\u00e8te appr\\u00e9cie et reconna\\u00eet imm\\u00e9diatement la beaut\\u00e9 sobre et \\nsauvage de l'Amazon Hunt II. D'admirateur, il se m\\u00e9tamorphose \\nin\\u00e9luctablement en joueur-se, puis en amant-e. Car l'Amazon est une \\nfemme, fatale \\u00e0 bien des \\u00e9gards, tou-te-s les grand-e-s joueur-euse-s \\nvous le diront. Dans la pr\\u00e9histoire de la K-F\\u00eat, des demi-dieux-d\\u00e9esses \\ndu flipper d\\u00e9sormais pass\\u00e9-e-s dans la l\\u00e9gende ont r\\u00e9dig\\u00e9 un trait\\u00e9 \\n(certain-e-s diront une bible, voire la Bible).

    \", \"type\": \"rich\"}, {\"value\": \"

    Le baby-foot

    \", \"type\": \"rich\"}, {\"value\": \"

    La d\\u00e9funte fun machine

    \\n\\n\\n\\nCette machine n'est plus. Mais elle reste dans le coeur de ceux qui \\nl'ont connue. C'est pourquoi cette section n'a pas \\u00e9t\\u00e9 retir\\u00e9e.

    Pour attaquer le cas \\u00e9trange de la machine bizarre qui tra\\u00eene \\u00e0 c\\u00f4t\\u00e9, \\ndisons simplement qu'elle s'appelle Monster Bash. On me souffle en r\\u00e9gie\\n que pour une fun machine, elle n'est pas si mal. De fait, elle t\\u00e9moigne\\n d'un humour d\\u00e9cal\\u00e9, absurde et parfois involontaire : ainsi, la \\ntraduction oscille entre le path\\u00e9tique et l'ignoble, en passant par le \\nburlesque. Le but est de r\\u00e9veiller et vaincre six monstres, parmi \\nlesquels Dracula et Frankenstein, pour les asservir et les rassembler \\ndans le plus grand groupe de rock de l'histoire : les \\u00abmonsters of rock\\u00bb\\n (traduction : \\u00abmonstres du rocher\\u00bb). Il n'y a pas pour le moment de \\ntrait\\u00e9 th\\u00e9orique de la Monster Bash, la jeu se r\\u00e9sumant de toute fa\\u00e7on \\u00e0\\n \\u00abmoi voir, moi actionner flip\\u00bb. Ce qui n'emp\\u00eache pas la machine en \\nquestion d'avoir son public d'habitu\\u00e9-e-s, bien au contraire. \\n

    \", \"type\": \"rich\"}]", + "layout": "kfet/base_col_mult.html", + "no_header": false, + "col_count": "" + } +}, +{ + "pk": 10, + "model": "kfetcms.kfetpage", + "fields": { + "main_size": "", + "content": "[{\"value\": \"

    Responsable de la publication

    • Il s'agit de la pr\\u00e9sidence du COF :
      Association des \\u00c9l\\u00e8ves de l'\\u00c9cole Normale Sup\\u00e9rieure

      45 rue d'Ulm

      75005 Paris
    \", \"type\": \"rich\"}, {\"value\": \"

    Informations prestataires

    • L'h\\u00e9bergement est fourni \\u00e0 titre gracieux par le CRI:
      \\u00c9cole Normale Sup\\u00e9rieure
      Centre de Ressources Informatiques
      45 rue d'Ulm
      75005 Paris
    • Le d\\u00e9veloppement est assur\\u00e9 par COF-Geek.
    \", \"type\": \"rich\"}]", + "layout": "kfet/base_col_mult.html", + "no_header": false, + "col_count": "" + } +}, +{ + "model": "wagtaildocs.document", + "pk": 1, + "fields": { + "created_at": "2017-05-30T04:20:00.000Z", + "uploaded_by_user": [ + "kfet_genericteam" + ], + "collection": 2, + "title": "K-F\u00eat - Plan d'acc\u00e8s", + "file": "documents/kfet_acces.pdf" + } +}, +{ + "model": "wagtaildocs.document", + "pk": 2, + "fields": { + "created_at": "2017-05-30T04:20:00.000Z", + "uploaded_by_user": [ + "kfet_genericteam" + ], + "collection": 2, + "title": "K-F\u00eat - Demande d'autorisation", + "file": "documents/kfet_autorisation.pdf" + } +}, +{ + "model": "wagtaildocs.document", + "pk": 3, + "fields": { + "created_at": "2017-05-30T04:20:00.000Z", + "uploaded_by_user": [ + "kfet_genericteam" + ], + "collection": 2, + "title": "K-F\u00eat - Trait\u00e9 de Flipper Th\u00e9orique", + "file": "documents/kfet_flipper.pdf" + } +}, +{ + "model": "wagtailimages.image", + "pk": 1, + "fields": { + "created_at": "2017-05-30T04:20:00.000Z", + "focal_point_width": null, + "height": 300, + "file": "original_images/kfet_amazon.jpg", + "collection": 2, + "focal_point_x": null, + "file_size": null, + "focal_point_height": null, + "focal_point_y": null, + "title": "K-F\u00eat - Amazon Hunt", + "width": 200, + "uploaded_by_user": [ + "kfet_genericteam" + ] + } +}, +{ + "model": "wagtailimages.image", + "pk": 2, + "fields": { + "created_at": "2017-05-30T04:20:00.000Z", + "focal_point_width": null, + "height": 300, + "file": "original_images/kfet_funmachine.jpg", + "collection": 2, + "focal_point_x": null, + "file_size": null, + "focal_point_height": null, + "focal_point_y": null, + "title": "K-F\u00eat - Fun Machine", + "width": 200, + "uploaded_by_user": [ + "kfet_genericteam" + ] + } +}, +{ + "fields": { + "width": 3020, + "file_size": null, + "file": "original_images/hugo_manet.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 3020, + "focal_point_width": null, + "focal_point_y": null, + "title": "Hugo Manet", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 3 +}, +{ + "fields": { + "width": 1566, + "file_size": null, + "file": "original_images/lisa_gourdon.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 1634, + "focal_point_width": null, + "focal_point_y": null, + "title": "Lisa Gourdon", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 4 +}, +{ + "fields": { + "width": 117, + "file_size": null, + "file": "original_images/pierre_quesselaire.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 153, + "focal_point_width": null, + "focal_point_y": null, + "title": "Pierre Quesselaire", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 5 +}, +{ + "fields": { + "width": 606, + "file_size": null, + "file": "original_images/thibault_scoquart.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 487, + "focal_point_width": null, + "focal_point_y": null, + "title": "Thibault Scoquard", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 6 +}, +{ + "fields": { + "width": 640, + "file_size": null, + "file": "original_images/arnaud_fanthomme.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 320, + "focal_point_width": null, + "focal_point_y": null, + "title": "Arnaud Fanthomme", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 7 +}, +{ + "fields": { + "width": 125, + "file_size": null, + "file": "original_images/vincent_balerdi.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 163, + "focal_point_width": null, + "focal_point_y": null, + "title": "Vincent Balerdi", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 8 +}, +{ + "fields": { + "width": 125, + "file_size": null, + "file": "original_images/nathanel_willaime.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 176, + "focal_point_width": null, + "focal_point_y": null, + "title": "Nathana\u00ebl Willaime", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 9 +}, +{ + "fields": { + "width": 125, + "file_size": null, + "file": "original_images/elisabeth_miller.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 146, + "focal_point_width": null, + "focal_point_y": null, + "title": "\u00c9lisabeth Miller", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 10 +}, +{ + "fields": { + "width": 720, + "file_size": null, + "file": "original_images/arthur_lesage.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 720, + "focal_point_width": null, + "focal_point_y": null, + "title": "Arthur Lesage", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 11 +}, +{ + "fields": { + "width": 445, + "file_size": null, + "file": "original_images/sarah_asset.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 436, + "focal_point_width": null, + "focal_point_y": null, + "title": "Sarah Asset", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 12 +}, +{ + "fields": { + "width": 480, + "file_size": null, + "file": "original_images/alexandre_legrand.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 360, + "focal_point_width": null, + "focal_point_y": null, + "title": "Alexandre Legrand", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 13 +}, +{ + "fields": { + "width": 4608, + "file_size": null, + "file": "original_images/etienne_baudel.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 3456, + "focal_point_width": null, + "focal_point_y": null, + "title": "\u00c9tienne Baudel", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 14 +}, +{ + "fields": { + "width": 358, + "file_size": null, + "file": "original_images/marine_snape.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 435, + "focal_point_width": null, + "focal_point_y": null, + "title": "Marine Snape", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 15 +}, +{ + "fields": { + "width": 121, + "file_size": null, + "file": "original_images/anatole_gosset.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 200, + "focal_point_width": null, + "focal_point_y": null, + "title": "Anatole Gosset", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 16 +}, +{ + "fields": { + "width": 253, + "file_size": null, + "file": "original_images/jacko_rastikian.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 338, + "focal_point_width": null, + "focal_point_y": null, + "title": "Jacko Rastikian", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 17 +}, +{ + "fields": { + "width": 285, + "file_size": null, + "file": "original_images/alexandre_jannaud.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 380, + "focal_point_width": null, + "focal_point_y": null, + "title": "Alexandre Jannaud", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 18 +}, +{ + "fields": { + "width": 283, + "file_size": null, + "file": "original_images/aurelien_delobelle.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 371, + "focal_point_width": null, + "focal_point_y": null, + "title": "Aur\u00e9lien Delobelle", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 19 +}, +{ + "fields": { + "width": 125, + "file_size": null, + "file": "original_images/sylvain_douteau.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 161, + "focal_point_width": null, + "focal_point_y": null, + "title": "Sylvain Douteau", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 20 +}, +{ + "fields": { + "width": 125, + "file_size": null, + "file": "original_images/raphael_lescanne.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 176, + "focal_point_width": null, + "focal_point_y": null, + "title": "Rapha\u00ebl Lescanne", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 21 +}, +{ + "fields": { + "width": 124, + "file_size": null, + "file": "original_images/romain_gourvil.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 157, + "focal_point_width": null, + "focal_point_y": null, + "title": "Romain Gourvil", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 22 +}, +{ + "fields": { + "width": 133, + "file_size": null, + "file": "original_images/marie_labeye.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 163, + "focal_point_width": null, + "focal_point_y": null, + "title": "Marie Labeye", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 23 +}, +{ + "fields": { + "width": 127, + "file_size": null, + "file": "original_images/oscar_blumberg.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 159, + "focal_point_width": null, + "focal_point_y": null, + "title": "Oscar Blumberg", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 24 +}, +{ + "fields": { + "width": 210, + "file_size": null, + "file": "original_images/zaid_allybokus.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 311, + "focal_point_width": null, + "focal_point_y": null, + "title": "Za\u00efd Allybokus", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 25 +}, +{ + "fields": { + "width": 495, + "file_size": null, + "file": "original_images/damien_garreau.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 548, + "focal_point_width": null, + "focal_point_y": null, + "title": "Damien Garreau", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 26 +}, +{ + "fields": { + "width": 323, + "file_size": null, + "file": "original_images/andrea_londono.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 458, + "focal_point_width": null, + "focal_point_y": null, + "title": "Andr\u00e9a Londono-Lopez", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 27 +}, +{ + "fields": { + "width": 120, + "file_size": null, + "file": "original_images/tristan_roussel.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 155, + "focal_point_width": null, + "focal_point_y": null, + "title": "Tristan Roussel", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 28 +}, +{ + "fields": { + "width": 427, + "file_size": null, + "file": "original_images/guillaume_vernade.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 640, + "focal_point_width": null, + "focal_point_y": null, + "title": "Guillaume Vernade", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 29 +}, +{ + "fields": { + "width": 2304, + "file_size": null, + "file": "original_images/lucas_mercier.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 3020, + "focal_point_width": null, + "focal_point_y": null, + "title": "Lucas Mercier", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 30 +}, +{ + "fields": { + "width": 199, + "file_size": null, + "file": "original_images/francois_maillot.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 240, + "focal_point_width": null, + "focal_point_y": null, + "title": "Fran\u00e7ois Maillot", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 31 +}, +{ + "fields": { + "width": 965, + "file_size": null, + "file": "original_images/fabrice_catoire.jpg", + "focal_point_height": null, + "focal_point_x": null, + "height": 1255, + "focal_point_width": null, + "focal_point_y": null, + "title": "Fabrice Catoire", + "collection": 2, + "uploaded_by_user": [ + "kfet_genericteam" + ], + "created_at": "2017-05-30T04:20:00.000Z" + }, + "model": "wagtailimages.image", + "pk": 32 +}, +{ + "model": "wagtailmenus.flatmenu", + "fields": { + "max_levels": 1, + "handle": "kfet-nav", + "site": [ + "localhost", + 8000 + ], + "use_specific": 1, + "heading": "", + "title": "K-F\u00eat - Navigation" + }, + "pk": 1 +}, +{ + "pk": 1, + "fields": { + "link_page": 3, + "menu": 1, + "allow_subnav": true, + "handle": "", + "link_text": "Accueil", + "link_url": "", + "sort_order": 0, + "url_append": "" + }, + "model": "wagtailmenus.flatmenuitem" +}, +{ + "pk": 2, + "fields": { + "link_page": 4, + "menu": 1, + "allow_subnav": true, + "handle": "", + "link_text": "", + "link_url": "", + "sort_order": 1, + "url_append": "" + }, + "model": "wagtailmenus.flatmenuitem" +}, +{ + "pk": 3, + "fields": { + "link_page": 5, + "menu": 1, + "allow_subnav": true, + "handle": "", + "link_text": "", + "link_url": "", + "sort_order": 2, + "url_append": "" + }, + "model": "wagtailmenus.flatmenuitem" +}, +{ + "pk": 4, + "fields": { + "link_page": 6, + "menu": 1, + "allow_subnav": true, + "handle": "", + "link_text": "", + "link_url": "", + "sort_order": 3, + "url_append": "" + }, + "model": "wagtailmenus.flatmenuitem" +}, +{ + "pk": 5, + "fields": { + "link_page": 7, + "menu": 1, + "allow_subnav": true, + "handle": "", + "link_text": "", + "link_url": "", + "sort_order": 4, + "url_append": "" + }, + "model": "wagtailmenus.flatmenuitem" +}, +{ + "pk": 6, + "fields": { + "link_page": 8, + "menu": 1, + "allow_subnav": true, + "handle": "", + "link_text": "", + "link_url": "", + "sort_order": 5, + "url_append": "" + }, + "model": "wagtailmenus.flatmenuitem" +} +] diff --git a/kfet/cms/hooks.py b/kfet/cms/hooks.py new file mode 100644 index 00000000..e58aeef5 --- /dev/null +++ b/kfet/cms/hooks.py @@ -0,0 +1,12 @@ +from django.contrib.staticfiles.templatetags.staticfiles import static +from django.utils.html import format_html + +from wagtail.wagtailcore import hooks + + +@hooks.register('insert_editor_css') +def editor_css(): + return format_html( + '', + static('kfetcms/css/editor.css'), + ) diff --git a/kfet/cms/management/commands/kfet_loadwagtail.py b/kfet/cms/management/commands/kfet_loadwagtail.py new file mode 100644 index 00000000..86b94d3e --- /dev/null +++ b/kfet/cms/management/commands/kfet_loadwagtail.py @@ -0,0 +1,35 @@ +from django.contrib.auth.models import Group +from django.core.management import call_command +from django.core.management.base import BaseCommand + +from wagtail.wagtailcore.models import Page, Site + + +class Command(BaseCommand): + help = "Importe des données pour Wagtail" + + def add_arguments(self, parser): + parser.add_argument('--file', default='kfet_wagtail_17_05') + + def handle(self, *args, **options): + + self.stdout.write("Import des données wagtail") + + # Nettoyage des données initiales posées par Wagtail dans la migration + # wagtailcore/0002 + + Group.objects.filter(name__in=('Moderators', 'Editors')).delete() + + try: + homepage = Page.objects.get( + title="Welcome to your new Wagtail site!" + ) + homepage.delete() + Site.objects.filter(root_page=homepage).delete() + except Page.DoesNotExist: + pass + + # Import des données + # Par défaut, il s'agit d'une copie du site K-Fêt (17-05) + + call_command('loaddata', options['file']) diff --git a/kfet/cms/migrations/0001_initial.py b/kfet/cms/migrations/0001_initial.py new file mode 100644 index 00000000..ed0b0948 --- /dev/null +++ b/kfet/cms/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import wagtail.wagtailsnippets.blocks +import wagtail.wagtailcore.blocks +import wagtail.wagtailcore.fields +import django.db.models.deletion +import kfet.cms.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0033_remove_golive_expiry_help_text'), + ('wagtailimages', '0019_delete_filter'), + ] + + operations = [ + migrations.CreateModel( + name='KFetPage', + fields=[ + ('page_ptr', models.OneToOneField(serialize=False, primary_key=True, parent_link=True, auto_created=True, to='wagtailcore.Page', on_delete=models.CASCADE)), + ('no_header', models.BooleanField(verbose_name='Sans en-tête', help_text="Coché, l'en-tête (avec le titre) de la page n'est pas affiché.", default=False)), + ('content', wagtail.wagtailcore.fields.StreamField((('rich', wagtail.wagtailcore.blocks.RichTextBlock(label='Éditeur')), ('carte', kfet.cms.models.MenuBlock()), ('group_team', wagtail.wagtailcore.blocks.StructBlock((('show_only', wagtail.wagtailcore.blocks.IntegerBlock(help_text='Nombre initial de membres affichés. Laisser vide pour tou-te-s les afficher.', required=False, label='Montrer seulement')), ('members', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailsnippets.blocks.SnippetChooserBlock(kfet.cms.models.MemberTeam), classname='team-group', label='K-Fêt-eux-ses'))))), ('group', wagtail.wagtailcore.blocks.StreamBlock((('rich', wagtail.wagtailcore.blocks.RichTextBlock(label='Éditeur')), ('carte', kfet.cms.models.MenuBlock()), ('group_team', wagtail.wagtailcore.blocks.StructBlock((('show_only', wagtail.wagtailcore.blocks.IntegerBlock(help_text='Nombre initial de membres affichés. Laisser vide pour tou-te-s les afficher.', required=False, label='Montrer seulement')), ('members', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailsnippets.blocks.SnippetChooserBlock(kfet.cms.models.MemberTeam), classname='team-group', label='K-Fêt-eux-ses')))))), label='Contenu groupé'))), verbose_name='Contenu')), + ('layout', models.CharField(max_length=255, choices=[('kfet/base_col_1.html', 'Une colonne : centrée sur la page'), ('kfet/base_col_2.html', 'Deux colonnes : fixe à gauche, contenu à droite'), ('kfet/base_col_mult.html', 'Contenu scindé sur plusieurs colonnes')], help_text='Comment cette page devrait être affichée ?', verbose_name='Template', default='kfet/base_col_mult.html')), + ('main_size', models.CharField(max_length=255, blank=True, verbose_name='Taille de la colonne de contenu')), + ('col_count', models.CharField(max_length=255, blank=True, verbose_name='Nombre de colonnes', help_text="S'applique au page dont le contenu est scindé sur plusieurs colonnes")), + ], + options={ + 'verbose_name': 'page K-Fêt', + 'verbose_name_plural': 'pages K-Fêt', + }, + bases=('wagtailcore.page',), + ), + migrations.CreateModel( + name='MemberTeam', + fields=[ + ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), + ('first_name', models.CharField(blank=True, max_length=255, verbose_name='Prénom', default='')), + ('last_name', models.CharField(blank=True, max_length=255, verbose_name='Nom', default='')), + ('nick_name', models.CharField(verbose_name='Alias', blank=True, default='', max_length=255)), + ('photo', models.ForeignKey(null=True, related_name='+', on_delete=django.db.models.deletion.SET_NULL, verbose_name='Photo', blank=True, to='wagtailimages.Image')), + ], + options={ + 'verbose_name': 'K-Fêt-eux-se', + }, + ), + ] diff --git a/kfet/cms/migrations/0002_alter_kfetpage_colcount.py b/kfet/cms/migrations/0002_alter_kfetpage_colcount.py new file mode 100644 index 00000000..fe91d3e6 --- /dev/null +++ b/kfet/cms/migrations/0002_alter_kfetpage_colcount.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfetcms', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='kfetpage', + name='col_count', + field=models.CharField(blank=True, max_length=255, verbose_name='Nombre de colonnes', help_text="S'applique au page dont le contenu est scindé sur plusieurs colonnes."), + ), + ] diff --git a/kfet/cms/migrations/__init__.py b/kfet/cms/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kfet/cms/models.py b/kfet/cms/models.py new file mode 100644 index 00000000..0dff183f --- /dev/null +++ b/kfet/cms/models.py @@ -0,0 +1,174 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from wagtail.wagtailadmin.edit_handlers import ( + FieldPanel, FieldRowPanel, MultiFieldPanel, StreamFieldPanel +) +from wagtail.wagtailcore import blocks +from wagtail.wagtailcore.fields import StreamField +from wagtail.wagtailcore.models import Page +from wagtail.wagtailimages.edit_handlers import ImageChooserPanel +from wagtail.wagtailsnippets.blocks import SnippetChooserBlock +from wagtail.wagtailsnippets.models import register_snippet + +from kfet.cms.context_processors import get_articles + + +@register_snippet +class MemberTeam(models.Model): + first_name = models.CharField( + verbose_name=_('Prénom'), + blank=True, default='', max_length=255, + ) + last_name = models.CharField( + verbose_name=_('Nom'), + blank=True, default='', max_length=255, + ) + nick_name = models.CharField( + verbose_name=_('Alias'), + blank=True, default='', max_length=255, + ) + photo = models.ForeignKey( + 'wagtailimages.Image', + verbose_name=_('Photo'), + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='+', + ) + + class Meta: + verbose_name = _('K-Fêt-eux-se') + + panels = [ + FieldPanel('first_name'), + FieldPanel('last_name'), + FieldPanel('nick_name'), + ImageChooserPanel('photo'), + ] + + def __str__(self): + return self.get_full_name() + + def get_full_name(self): + return '{} {}'.format(self.first_name, self.last_name).strip() + + +class MenuBlock(blocks.StaticBlock): + class Meta: + icon = 'list-ul' + label = _('Carte') + template = 'kfetcms/block_menu.html' + + def get_context(self, *args, **kwargs): + context = super().get_context(*args, **kwargs) + context.update(get_articles()) + return context + + +class GroupTeamBlock(blocks.StructBlock): + show_only = blocks.IntegerBlock( + label=_('Montrer seulement'), + required=False, + help_text=_( + 'Nombre initial de membres affichés. Laisser vide pour tou-te-s ' + 'les afficher.' + ), + ) + members = blocks.ListBlock( + SnippetChooserBlock(MemberTeam), + label=_('K-Fêt-eux-ses'), + classname='team-group', + ) + + class Meta: + icon = 'group' + label = _('Groupe de K-Fêt-eux-ses') + template = 'kfetcms/block_teamgroup.html' + + +class ChoicesStreamBlock(blocks.StreamBlock): + rich = blocks.RichTextBlock(label=_('Éditeur')) + carte = MenuBlock() + group_team = GroupTeamBlock() + + +class KFetStreamBlock(ChoicesStreamBlock): + group = ChoicesStreamBlock(label=_('Contenu groupé')) + + +class KFetPage(Page): + + content = StreamField(KFetStreamBlock, verbose_name=_('Contenu')) + + # Layout fields + + TEMPLATE_COL_1 = 'kfet/base_col_1.html' + TEMPLATE_COL_2 = 'kfet/base_col_2.html' + TEMPLATE_COL_MULT = 'kfet/base_col_mult.html' + + no_header = models.BooleanField( + verbose_name=_('Sans en-tête'), + default=False, + help_text=_( + "Coché, l'en-tête (avec le titre) de la page n'est pas affiché." + ), + ) + layout = models.CharField( + verbose_name=_('Template'), + choices=[ + (TEMPLATE_COL_1, _('Une colonne : centrée sur la page')), + (TEMPLATE_COL_2, _('Deux colonnes : fixe à gauche, contenu à droite')), + (TEMPLATE_COL_MULT, _('Contenu scindé sur plusieurs colonnes')), + ], + default=TEMPLATE_COL_MULT, max_length=255, + help_text=_( + "Comment cette page devrait être affichée ?" + ), + ) + main_size = models.CharField( + verbose_name=_('Taille de la colonne de contenu'), + blank=True, max_length=255, + ) + col_count = models.CharField( + verbose_name=_('Nombre de colonnes'), + blank=True, max_length=255, + help_text=_( + "S'applique au page dont le contenu est scindé sur plusieurs colonnes." + ), + ) + + # Panels + + content_panels = Page.content_panels + [ + StreamFieldPanel('content'), + ] + + layout_panel = [ + FieldPanel('no_header'), + FieldPanel('layout'), + FieldRowPanel([ + FieldPanel('main_size'), + FieldPanel('col_count'), + ]), + ] + + settings_panels = [ + MultiFieldPanel(layout_panel, _('Affichage')) + ] + Page.settings_panels + + # Base template + template = "kfetcms/base.html" + + class Meta: + verbose_name = _('page K-Fêt') + verbose_name_plural = _('pages K-Fêt') + + def get_context(self, request, *args, **kwargs): + context = super().get_context(request, *args, **kwargs) + + page = context['page'] + + if not page.seo_title: + page.seo_title = page.title + + return context diff --git a/kfet/cms/static/kfetcms/css/base.css b/kfet/cms/static/kfetcms/css/base.css new file mode 100644 index 00000000..b580ef94 --- /dev/null +++ b/kfet/cms/static/kfetcms/css/base.css @@ -0,0 +1,93 @@ +.main.cms { + padding: 20px 15px; +} + +@media (min-width: 768px) { + .main.cms { + padding: 35px 30px; + } +} + +.cms { + text-align: justify; + font-size: 1.1em; +} + +@media (min-width:768px) { + .cms { + font-size: 1.2em; + line-height: 1.6em; + } +} + +/* Titles */ + +.cms h2, .cms h3 { + clear: both; + margin: 0 0 15px; + padding-bottom: 10px; + border-bottom: 1px solid #c8102e; + text-align: left; + font-weight: bold; +} + +@media (min-width: 768px) { + .cms h2, .cms h3 { + padding-bottom: 15px; + } +} + +/* Paragraphs */ + +.cms p { + margin-bottom: 20px; + text-indent: 2em; +} + +.cms p + :not(h2):not(h3):not(div) { + margin-top: -10px; +} + +@media (min-width: 768px) { + .cms p { + padding-bottom: 15px; + } + + .cms p + :not(h2):not(h3):not(div) { + margin-top: -30px; + } +} + + +/* Lists */ + +.cms ol, .cms ul { + padding: 0 0 0 15px; + margin: 0 0 10px; +} + +.cms ul { + list-style-type: square; +} + +.cms ol > li, .cms ul > li { + padding-left: 5px; +} + + +/* Images */ + +.cms .richtext-image { + max-height: 100%; + margin: 5px 0 15px; +} + +.cms .richtext-image.left { + float: left; + margin-right: 30px; +} + +.cms .richtext-image.right { + float: right; + margin-left: 30px; +} diff --git a/kfet/cms/static/kfetcms/css/editor.css b/kfet/cms/static/kfetcms/css/editor.css new file mode 100644 index 00000000..f97e9895 --- /dev/null +++ b/kfet/cms/static/kfetcms/css/editor.css @@ -0,0 +1,18 @@ +.snippets.listing thead, .snippets.listing thead tr { + border: 0; +} + +.snippets.listing tbody { + display: block; + column-count: 2; +} + +.snippets.listing tbody tr { + display: block; +} + +@media (min-width: 992px) { + .snippets.listing tbody { + column-count: 3; + } +} diff --git a/kfet/cms/static/kfetcms/css/index.css b/kfet/cms/static/kfetcms/css/index.css new file mode 100644 index 00000000..647515e3 --- /dev/null +++ b/kfet/cms/static/kfetcms/css/index.css @@ -0,0 +1,3 @@ +@import url("base.css"); +@import url("menu.css"); +@import url("team.css"); diff --git a/kfet/cms/static/kfetcms/css/menu.css b/kfet/cms/static/kfetcms/css/menu.css new file mode 100644 index 00000000..f1952c69 --- /dev/null +++ b/kfet/cms/static/kfetcms/css/menu.css @@ -0,0 +1,58 @@ +.carte { + margin-bottom: 15px; + font-family: "Roboto Slab"; +} + +.carte .carte-title { + padding-top: 0; + margin-top: 0; + margin-bottom: 0; +} + +.carte .carte-list { + width: 100%; + padding: 15px; + list-style-type: none; +} + +.carte .carte-item { + position: relative; + text-align: right; + white-space: nowrap; + padding: 0; +} + +.carte .carte-item .filler { + position: absolute; + left: 0; + right: 0; + border-bottom: 2px dotted #333; + height: 75%; +} + +.carte .carte-item > span { + position: relative; +} + +.carte .carte-item .carte-label { + background: white; + float: left; + padding-right: 10px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.carte .carte-item .carte-ukf { + padding: 0 10px; + background: #ffdbc7; +} + +.carte-inverted .carte-list, +.carte-inverted .carte-item .carte-label { + background: #ffdbc7; +} + +.carte-inverted .carte-item .carte-ukf { + background: white; +} diff --git a/kfet/cms/static/kfetcms/css/team.css b/kfet/cms/static/kfetcms/css/team.css new file mode 100644 index 00000000..e663fc66 --- /dev/null +++ b/kfet/cms/static/kfetcms/css/team.css @@ -0,0 +1,47 @@ +.team-group { + margin-bottom: 20px; +} + +.team-group .col-btn { + margin-bottom: 20px; +} + +.team-group .member-more { + display: none; +} + +.team-member { + padding: 0; + margin-bottom: 20px; + min-height: 190px; + background-color: inherit; + border: 0; +} + +.team-member img { + max-width: 100%; + max-height: 125px; + width: auto; + height: auto; + display: block; +} + +.team-member .infos { + height: 50px; + margin-top: 15px; +} + +@media (min-width: 768px) { + .team-group { + margin-left: 20px; + margin-right: 20px; + } + + .team-member { + min-height: 215px; + } + + .team-member img { + max-height: 150px; + } +} diff --git a/kfet/cms/templates/kfetcms/base.html b/kfet/cms/templates/kfetcms/base.html new file mode 100644 index 00000000..3b9f40d6 --- /dev/null +++ b/kfet/cms/templates/kfetcms/base.html @@ -0,0 +1,41 @@ +{% extends page.layout %} +{% load static wagtailcore_tags wagtailuserbar %} + +{# CSS/JS #} + +{% block extra_head %} +{{ block.super }} + +{% endblock %} + +{# Titles #} + +{% block title %}{{ page.seo_title }}{% endblock %} +{% block header-title %}{{ page.title }}{% endblock %} + +{# Layout #} + +{% block main-size %}{{ page.main_size|default:block.super }}{% endblock %} +{% block mult-count %}{{ page.col_count|default:block.super }}{% endblock %} + +{% block main-class %}cms main-bg{% endblock %} + +{# Content #} + +{% block main %} + +{% for block in page.content %} +
    + {% include_block block %} +
    +{% endfor %} + +{% wagtailuserbar %} + +{% endblock %} + +{# Footer #} + +{% block footer %} +{% include "kfet/base_footer.html" %} +{% endblock %} diff --git a/kfet/cms/templates/kfetcms/block_menu.html b/kfet/cms/templates/kfetcms/block_menu.html new file mode 100644 index 00000000..382a7770 --- /dev/null +++ b/kfet/cms/templates/kfetcms/block_menu.html @@ -0,0 +1,11 @@ +{% load static %} + +{% if pressions %} + {% include "kfetcms/block_menu_category.html" with title="Pressions du moment" articles=pressions class="carte-inverted" %} +{% endif %} + +{% regroup articles by category as categories %} + +{% for category in categories %} + {% include "kfetcms/block_menu_category.html" with title=category.grouper.name articles=category.list %} +{% endfor %} diff --git a/kfet/cms/templates/kfetcms/block_menu_category.html b/kfet/cms/templates/kfetcms/block_menu_category.html new file mode 100644 index 00000000..ef7d4ce0 --- /dev/null +++ b/kfet/cms/templates/kfetcms/block_menu_category.html @@ -0,0 +1,12 @@ +
    +

    {{ title }}

    +
      + {% for article in articles %} +
    • +
      + {{ article.name }} + {{ article.price_ukf }} UKF +
    • + {% endfor %} +
    +
    diff --git a/kfet/cms/templates/kfetcms/block_teamgroup.html b/kfet/cms/templates/kfetcms/block_teamgroup.html new file mode 100644 index 00000000..fab43d68 --- /dev/null +++ b/kfet/cms/templates/kfetcms/block_teamgroup.html @@ -0,0 +1,66 @@ +{% load wagtailcore_tags wagtailimages_tags %} + + +{% with groupteam=value len=value.members|length %} + +
    + + {% if len == 2 %} +
    + {% endif %} + + {% for member in groupteam.members %} +
    +
    + {% image member.photo max-200x500 %} +
    + {{ member.get_full_name }} +
    + {% if member.nick_name %} + alias {{ member.nick_name }} + {% endif %} +
    +
    +
    + {% endfor %} + + {% if groupteam.show_only != None and len > groupteam.show_only %} +
    + +
    + {% endif %} + +
    + +{% endwith %} + + 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 ee096368..a53bbb72 100644 --- a/kfet/consumers.py +++ b/kfet/consumers.py @@ -1,39 +1,4 @@ -# -*- coding: utf-8 -*- - -from django.core.serializers.json import json, DjangoJSONEncoder - -from channels.generic.websockets import JsonWebsocketConsumer - - -class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer): - """Custom Json Websocket Consumer. - - Encode to JSON with DjangoJSONEncoder. - - """ - - @classmethod - def encode_json(cls, content): - return json.dumps(content, cls=DjangoJSONEncoder) - - -class PermConsumerMixin(object): - """Add support to check permissions on Consumers. - - Attributes: - perms_connect (list): Required permissions to connect to this - consumer. - - """ - http_user = True # Enable message.user - perms_connect = [] - - def connect(self, message, **kwargs): - """Check permissions on connection.""" - if message.user.has_perms(self.perms_connect): - super().connect(message, **kwargs) - else: - self.close() +from .utils import DjangoJsonWebsocketConsumer, PermConsumerMixin class KPsul(PermConsumerMixin, DjangoJsonWebsocketConsumer): diff --git a/kfet/context_processors.py b/kfet/context_processors.py index 4c7b4fe4..89678f62 100644 --- a/kfet/context_processors.py +++ b/kfet/context_processors.py @@ -1,18 +1,5 @@ -# -*- coding: utf-8 -*- - -from django.contrib.auth.context_processors import PermWrapper - from kfet.config import kfet_config -def auth(request): - if hasattr(request, 'real_user'): - return { - 'user': request.real_user, - 'perms': PermWrapper(request.real_user), - } - return {} - - def config(request): return {'kfet_config': 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 09c3a4d0..522f20de 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - from datetime import timedelta from decimal import Decimal from django import forms from django.core.exceptions import ValidationError -from django.core.validators import MinLengthValidator -from django.contrib.auth.models import User, Group, Permission -from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import User from django.forms import modelformset_factory from django.utils import timezone @@ -19,24 +15,25 @@ from kfet.models import ( TransferGroup, Supplier) from gestioncof.models import CofProfile +from .auth.forms import UserGroupForm # noqa + # ----- # Widgets # ----- class DateTimeWidget(forms.DateTimeInput): - def __init__(self, attrs = None): - super(DateTimeWidget, self).__init__(attrs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.attrs['format'] = '%Y-%m-%d %H:%M' + class Media: css = { - 'all': ('kfet/css/bootstrap-datetimepicker.min.css',) - } - js = ( - 'kfet/js/moment.js', - 'kfet/js/moment-fr.js', - 'kfet/js/bootstrap-datetimepicker.min.js', - ) + 'all': ('kfet/css/bootstrap-datetimepicker.min.css',) + } + js = ('kfet/js/bootstrap-datetimepicker.min.js',) + + # ----- # Account forms # ----- @@ -45,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 @@ -78,14 +75,19 @@ class AccountRestrictForm(AccountForm): class Meta(AccountForm.Meta): fields = ['is_frozen'] + class AccountPwdForm(forms.Form): pwd1 = forms.CharField( - label="Mot de passe K-Fêt", - help_text="Le mot de passe doit contenir au moins huit caractères", - widget=forms.PasswordInput) + label="Mot de passe K-Fêt", + required=False, + help_text="Le mot de passe doit contenir au moins huit caractères", + widget=forms.PasswordInput, + ) pwd2 = forms.CharField( - label="Confirmer le mot de passe", - widget=forms.PasswordInput) + label="Confirmer le mot de passe", + required=False, + widget=forms.PasswordInput, + ) def clean(self): pwd1 = self.cleaned_data.get('pwd1', '') @@ -94,7 +96,8 @@ 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): @@ -111,21 +114,16 @@ class CofRestrictForm(CofForm): class Meta(CofForm.Meta): fields = ['departement'] -class UserForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - from_clipper = kwargs.pop('from_clipper', False) - new_user = kwargs.get('instance') is None and not from_clipper - super(UserForm, self).__init__(*args, **kwargs) - if new_user: - self.fields['username'].validators = [MinLengthValidator(9)] +class UserForm(forms.ModelForm): class Meta: - model = User + model = User fields = ['username', 'first_name', 'last_name', 'email'] help_texts = { 'username': '' } + class UserRestrictForm(UserForm): class Meta(UserForm.Meta): fields = ['first_name', 'last_name'] @@ -135,35 +133,6 @@ class UserRestrictTeamForm(UserForm): fields = ['first_name', 'last_name', 'email'] -class UserGroupForm(forms.ModelForm): - groups = forms.ModelMultipleChoiceField( - Group.objects.filter(name__icontains='K-Fêt'), - label='Statut équipe', - required=False) - - def clean_groups(self): - kfet_groups = self.cleaned_data.get('groups') - other_groups = self.instance.groups.exclude(name__icontains='K-Fêt') - return list(kfet_groups) + list(other_groups) - - class Meta: - model = User - fields = ['groups'] - - -class GroupForm(forms.ModelForm): - permissions = forms.ModelMultipleChoiceField( - queryset= Permission.objects.filter(content_type__in= - ContentType.objects.filter(app_label='kfet'))) - - def clean_name(self): - name = self.cleaned_data['name'] - return 'K-Fêt %s' % name - - class Meta: - model = Group - fields = ['name', 'permissions'] - class AccountNegativeForm(forms.ModelForm): class Meta: model = AccountNegative @@ -232,7 +201,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: @@ -273,7 +242,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) @@ -287,7 +256,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 @@ -331,17 +300,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( @@ -358,7 +327,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') @@ -401,7 +370,7 @@ class AddcostForm(forms.Form): raise ValidationError('Compte invalide') else: self.cleaned_data['amount'] = 0 - super(AddcostForm, self).clean() + super().clean() # ----- @@ -445,8 +414,11 @@ class KFetConfigForm(ConfigForm): class FilterHistoryForm(forms.Form): - checkouts = forms.ModelMultipleChoiceField(queryset = Checkout.objects.all()) - accounts = forms.ModelMultipleChoiceField(queryset = Account.objects.all()) + checkouts = forms.ModelMultipleChoiceField(queryset=Checkout.objects.all()) + accounts = forms.ModelMultipleChoiceField(queryset=Account.objects.all()) + from_date = forms.DateTimeField(widget=DateTimeWidget) + to_date = forms.DateTimeField(widget=DateTimeWidget) + # ----- # Transfer forms @@ -496,7 +468,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'] @@ -518,18 +490,14 @@ 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'] self.category = kwargs['initial']['category'] self.category_name = kwargs['initial']['category__name'] self.box_capacity = kwargs['initial']['box_capacity'] - self.v_s1 = kwargs['initial']['v_s1'] - self.v_s2 = kwargs['initial']['v_s2'] - self.v_s3 = kwargs['initial']['v_s3'] - self.v_s4 = kwargs['initial']['v_s4'] - self.v_s5 = kwargs['initial']['v_s5'] + self.v_all = kwargs['initial']['v_all'] self.v_moy = kwargs['initial']['v_moy'] self.v_et = kwargs['initial']['v_et'] self.v_prev = kwargs['initial']['v_prev'] @@ -552,7 +520,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/management/commands/loadkfetdevdata.py b/kfet/management/commands/loadkfetdevdata.py index 7f2ec9a3..6dd25f29 100644 --- a/kfet/management/commands/loadkfetdevdata.py +++ b/kfet/management/commands/loadkfetdevdata.py @@ -147,3 +147,9 @@ class Command(MyBaseCommand): # --- call_command('createopes', '100', '7', '--transfers=20') + + # --- + # Wagtail CMS + # --- + + call_command('kfet_loadwagtail') diff --git a/kfet/middleware.py b/kfet/middleware.py deleted file mode 100644 index 9502d393..00000000 --- a/kfet/middleware.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- - -from django.contrib.auth.models import User - -from kfet.backends import KFetBackend - - -class KFetAuthenticationMiddleware(object): - """Authenticate another user for this request if KFetBackend succeeds. - - By the way, if a user is authenticated, we refresh its from db to add - values from CofProfile and Account of this user. - - """ - def process_request(self, request): - if request.user.is_authenticated(): - # avoid multiple db accesses in views and templates - user_pk = request.user.pk - request.user = ( - User.objects - .select_related('profile__account_kfet') - .get(pk=user_pk) - ) - - kfet_backend = KFetBackend() - temp_request_user = kfet_backend.authenticate(request) - if temp_request_user: - request.real_user = request.user - request.user = temp_request_user diff --git a/kfet/migrations/0054_update_promos.py b/kfet/migrations/0054_update_promos.py new file mode 100644 index 00000000..2691e903 --- /dev/null +++ b/kfet/migrations/0054_update_promos.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0053_created_at'), + ] + + 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)], default=2017, null=True), + ), + ] diff --git a/kfet/migrations/0055_move_permissions.py b/kfet/migrations/0055_move_permissions.py new file mode 100644 index 00000000..a418124c --- /dev/null +++ b/kfet/migrations/0055_move_permissions.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +def forwards_perms(apps, schema_editor): + """Safely delete content type for old kfet.GlobalPermissions model. + + Any permissions (except defaults) linked to this content type are updated + to link at its new content type. + Then, delete the content type. This will delete the three defaults + permissions which are assumed unused. + + """ + ContentType = apps.get_model('contenttypes', 'contenttype') + try: + ctype_global = ContentType.objects.get( + app_label="kfet", model="globalpermissions", + ) + except ContentType.DoesNotExist: + # We are not migrating from existing data, nothing to do. + return + + perms = { + 'account': ( + 'is_team', 'manage_perms', 'manage_addcosts', + 'edit_balance_account', 'change_account_password', + 'special_add_account', + ), + 'accountnegative': ('view_negs',), + 'inventory': ('order_to_inventory',), + 'operation': ( + 'perform_deposit', 'perform_negative_operations', + 'override_frozen_protection', 'cancel_old_operations', + 'perform_commented_operations', + ), + } + + Permission = apps.get_model('auth', 'permission') + global_perms = Permission.objects.filter(content_type=ctype_global) + + for modelname, codenames in perms.items(): + model = apps.get_model('kfet', modelname) + ctype = ContentType.objects.get_for_model(model) + ( + global_perms + .filter(codename__in=codenames) + .update(content_type=ctype) + ) + + ctype_global.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0054_delete_settings'), + ('contenttypes', '__latest__'), + ('auth', '__latest__'), + ] + + operations = [ + migrations.AlterModelOptions( + name='account', + options={'permissions': (('is_team', 'Is part of the team'), ('manage_perms', 'Gérer les permissions K-Fêt'), ('manage_addcosts', 'Gérer les majorations'), ('edit_balance_account', "Modifier la balance d'un compte"), ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', 'Créer un compte avec une balance initiale'))}, + ), + migrations.AlterModelOptions( + name='accountnegative', + options={'permissions': (('view_negs', 'Voir la liste des négatifs'),)}, + ), + migrations.AlterModelOptions( + name='inventory', + options={'ordering': ['-at'], 'permissions': (('order_to_inventory', "Générer un inventaire à partir d'une commande"),)}, + ), + migrations.AlterModelOptions( + name='operation', + options={'permissions': (('perform_deposit', 'Effectuer une charge'), ('perform_negative_operations', 'Enregistrer des commandes en négatif'), ('override_frozen_protection', "Forcer le gel d'un compte"), ('cancel_old_operations', 'Annuler des commandes non récentes'), ('perform_commented_operations', 'Enregistrer des commandes avec commentaires'))}, + ), + migrations.RunPython(forwards_perms), + ] diff --git a/kfet/migrations/0056_change_account_meta.py b/kfet/migrations/0056_change_account_meta.py new file mode 100644 index 00000000..3992bf3c --- /dev/null +++ b/kfet/migrations/0056_change_account_meta.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0055_move_permissions'), + ] + + operations = [ + migrations.AlterModelOptions( + name='account', + options={'permissions': (('is_team', 'Is part of the team'), ('manage_perms', 'Gérer les permissions K-Fêt'), ('manage_addcosts', 'Gérer les majorations'), ('edit_balance_account', "Modifier la balance d'un compte"), ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', 'Créer un compte avec une balance initiale'), ('can_force_close', 'Fermer manuellement la K-Fêt'))}, + ), + ] diff --git a/kfet/migrations/0057_merge.py b/kfet/migrations/0057_merge.py new file mode 100644 index 00000000..48f63399 --- /dev/null +++ b/kfet/migrations/0057_merge.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0056_change_account_meta'), + ('kfet', '0054_update_promos'), + ] + + operations = [ + ] diff --git a/kfet/migrations/0058_delete_genericteamtoken.py b/kfet/migrations/0058_delete_genericteamtoken.py new file mode 100644 index 00000000..ea8b55cd --- /dev/null +++ b/kfet/migrations/0058_delete_genericteamtoken.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0057_merge'), + ] + + operations = [ + migrations.DeleteModel( + name='GenericTeamToken', + ), + ] diff --git a/kfet/migrations/0059_create_generic.py b/kfet/migrations/0059_create_generic.py new file mode 100644 index 00000000..4f04770c --- /dev/null +++ b/kfet/migrations/0059_create_generic.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +from kfet.auth import KFET_GENERIC_TRIGRAMME, KFET_GENERIC_USERNAME + + +def setup_kfet_generic_user(apps, schema_editor): + """ + Setup models instances for the kfet generic account. + + Username and trigramme are retrieved from kfet.auth.__init__ module. + Other data are registered here. + + See also setup_kfet_generic_user from kfet.auth.utils module. + """ + User = apps.get_model('auth', 'User') + CofProfile = apps.get_model('gestioncof', 'CofProfile') + Account = apps.get_model('kfet', 'Account') + + user, _ = User.objects.update_or_create( + username=KFET_GENERIC_USERNAME, + defaults={ + 'first_name': 'Compte générique K-Fêt', + }, + ) + profile, _ = CofProfile.objects.update_or_create(user=user) + account, _ = Account.objects.update_or_create( + cofprofile=profile, + defaults={ + 'trigramme': KFET_GENERIC_TRIGRAMME, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0058_delete_genericteamtoken'), + ] + + operations = [ + migrations.RunPython(setup_kfet_generic_user), + ] diff --git a/kfet/migrations/0060_amend_supplier.py b/kfet/migrations/0060_amend_supplier.py new file mode 100644 index 00000000..4eb569f8 --- /dev/null +++ b/kfet/migrations/0060_amend_supplier.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0059_create_generic'), + ] + + operations = [ + migrations.AlterField( + model_name='supplier', + name='address', + field=models.TextField(verbose_name='adresse', blank=True), + ), + migrations.AlterField( + model_name='supplier', + name='articles', + field=models.ManyToManyField(verbose_name='articles vendus', through='kfet.SupplierArticle', related_name='suppliers', to='kfet.Article'), + ), + migrations.AlterField( + model_name='supplier', + name='comment', + field=models.TextField(verbose_name='commentaire', blank=True), + ), + migrations.AlterField( + model_name='supplier', + name='email', + field=models.EmailField(max_length=254, verbose_name='adresse mail', blank=True), + ), + migrations.AlterField( + model_name='supplier', + name='phone', + field=models.CharField(max_length=20, verbose_name='téléphone', blank=True), + ), + ] diff --git a/kfet/migrations/0061_add_perms_config.py b/kfet/migrations/0061_add_perms_config.py new file mode 100644 index 00000000..01bdf51d --- /dev/null +++ b/kfet/migrations/0061_add_perms_config.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0060_amend_supplier'), + ] + + operations = [ + migrations.AlterModelOptions( + name='account', + options={'permissions': (('is_team', 'Is part of the team'), ('manage_perms', 'Gérer les permissions K-Fêt'), ('manage_addcosts', 'Gérer les majorations'), ('edit_balance_account', "Modifier la balance d'un compte"), ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', 'Créer un compte avec une balance initiale'), ('can_force_close', 'Fermer manuellement la K-Fêt'), ('see_config', 'Voir la configuration K-Fêt'), ('change_config', 'Modifier la configuration K-Fêt'))}, + ), + ] diff --git a/kfet/migrations/0062_delete_globalpermissions.py b/kfet/migrations/0062_delete_globalpermissions.py new file mode 100644 index 00000000..ee245412 --- /dev/null +++ b/kfet/migrations/0062_delete_globalpermissions.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('kfet', '0061_add_perms_config'), + ] + + operations = [ + migrations.DeleteModel( + name='GlobalPermissions', + ), + ] diff --git a/kfet/migrations/0063_promo.py b/kfet/migrations/0063_promo.py new file mode 100644 index 00000000..3fac5a8a --- /dev/null +++ b/kfet/migrations/0063_promo.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-05 21:47 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0062_delete_globalpermissions'), + ] + + 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=2017, null=True), + ), + ] diff --git a/kfet/models.py b/kfet/models.py index cc35df18..e952e85a 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -1,20 +1,23 @@ -# -*- coding: utf-8 -*- +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.urls import reverse 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 from datetime import date import re -import hashlib -from kfet.config import kfet_config +from .auth import KFET_GENERIC_TRIGRAMME +from .auth.models import GenericTeamToken # noqa + +from .config import kfet_config +from .utils import to_ukf def choices_length(choices): return reduce(lambda m, choice: max(m, len(choice[0])), choices, 0) @@ -32,6 +35,23 @@ class AccountManager(models.Manager): return super().get_queryset().select_related('cofprofile__user', 'negative') + def get_generic(self): + """ + Get the kfet generic account instance. + """ + return self.get(trigramme=KFET_GENERIC_TRIGRAMME) + + def get_by_password(self, password): + """ + Get a kfet generic account by clear password. + + Raises Account.DoesNotExist if no Account has this password. + """ + from .auth.utils import hash_password + if password is None: + raise self.model.DoesNotExist + return self.get(password=hash_password(password)) + class Account(models.Model): objects = AccountManager() @@ -63,10 +83,25 @@ class Account(models.Model): unique = True, blank = True, null = True, default = None) + class Meta: + permissions = ( + ('is_team', 'Is part of the team'), + ('manage_perms', 'Gérer les permissions K-Fêt'), + ('manage_addcosts', 'Gérer les majorations'), + ('edit_balance_account', "Modifier la balance d'un compte"), + ('change_account_password', + "Modifier le mot de passe d'une personne de l'équipe"), + ('special_add_account', + "Créer un compte avec une balance initiale"), + ('can_force_close', "Fermer manuellement la K-Fêt"), + ('see_config', "Voir la configuration K-Fêt"), + ('change_config', "Modifier la configuration K-Fêt"), + ) + def __str__(self): return '%s (%s)' % (self.trigramme, self.name) - # Propriétés pour accéder aux attributs de user et cofprofile et user + # Propriétés pour accéder aux attributs de cofprofile et user @property def user(self): return self.cofprofile.user @@ -90,6 +125,10 @@ class Account(models.Model): return self.cofprofile.is_cof # Propriétés supplémentaires + @property + def balance_ukf(self): + return to_ukf(self.balance, is_cof=self.is_cof) + @property def real_balance(self): if hasattr(self, 'negative') and self.negative.balance_offset: @@ -108,6 +147,14 @@ class Account(models.Model): def need_comment(self): return self.trigramme == '#13' + @property + def readable(self): + return self.trigramme != 'GNR' + + @property + def is_team(self): + return self.has_perm('kfet.is_team') + @staticmethod def is_validandfree(trigramme): data = { 'is_valid' : False, 'is_free' : False } @@ -208,12 +255,11 @@ 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, pwd): - pwd_sha256 = hashlib.sha256(pwd.encode('utf-8'))\ - .hexdigest() - self.password = pwd_sha256 + def change_pwd(self, clear_password): + from .auth.utils import hash_password + self.password = hash_password(clear_password) # Surcharge de delete # Pas de suppression possible @@ -284,6 +330,22 @@ class AccountNegative(models.Model): ) comment = models.CharField("commentaire", max_length=255, blank=True) + class Meta: + permissions = ( + ('view_negs', 'Voir la liste des négatifs'), + ) + + @property + def until_default(self): + 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( @@ -297,6 +359,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}) @@ -306,6 +370,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, @@ -316,7 +396,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, @@ -368,7 +448,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) @@ -382,10 +462,9 @@ 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) -@python_2_unicode_compatible class ArticleCategory(models.Model): name = models.CharField("nom", max_length=45) has_addcost = models.BooleanField("majorée", default=True, @@ -398,7 +477,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) @@ -436,6 +514,10 @@ class Article(models.Model): def get_absolute_url(self): return reverse('kfet.article.read', kwargs={'pk': self.pk}) + def price_ukf(self): + return to_ukf(self.price) + + class ArticleRule(models.Model): article_on = models.OneToOneField( Article, on_delete = models.PROTECT, @@ -462,6 +544,10 @@ class Inventory(models.Model): class Meta: ordering = ['-at'] + permissions = ( + ('order_to_inventory', "Générer un inventaire à partir d'une commande"), + ) + class InventoryArticle(models.Model): inventory = models.ForeignKey( @@ -477,23 +563,26 @@ 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) + -@python_2_unicode_compatible class Supplier(models.Model): articles = models.ManyToManyField( Article, - through = 'SupplierArticle', - related_name = "suppliers") - name = models.CharField("nom", max_length = 45) - address = models.TextField("adresse") - email = models.EmailField("adresse mail") - phone = models.CharField("téléphone", max_length = 10) - comment = models.TextField("commentaire") + verbose_name=_("articles vendus"), + through='SupplierArticle', + related_name='suppliers', + ) + name = models.CharField(_("nom"), max_length=45) + address = models.TextField(_("adresse"), blank=True) + email = models.EmailField(_("adresse mail"), blank=True) + phone = models.CharField(_("téléphone"), max_length=20, blank=True) + comment = models.TextField(_("commentaire"), blank=True) def __str__(self): return self.name + class SupplierArticle(models.Model): supplier = models.ForeignKey( Supplier, on_delete = models.PROTECT) @@ -638,6 +727,17 @@ class Operation(models.Model): max_digits=6, decimal_places=2, blank=True, null=True, default=None) + class Meta: + permissions = ( + ('perform_deposit', 'Effectuer une charge'), + ('perform_negative_operations', + 'Enregistrer des commandes en négatif'), + ('override_frozen_protection', "Forcer le gel d'un compte"), + ('cancel_old_operations', 'Annuler des commandes non récentes'), + ('perform_commented_operations', + 'Enregistrer des commandes avec commentaires'), + ) + @property def is_checkout(self): return (self.type == Operation.DEPOSIT or @@ -656,28 +756,3 @@ class Operation(models.Model): return templates[self.type].format(nb=self.article_nb, article=self.article, amount=self.amount) - - -class GlobalPermissions(models.Model): - class Meta: - managed = False - permissions = ( - ('is_team', 'Is part of the team'), - ('perform_deposit', 'Effectuer une charge'), - ('perform_negative_operations', - 'Enregistrer des commandes en négatif'), - ('override_frozen_protection', "Forcer le gel d'un compte"), - ('cancel_old_operations', 'Annuler des commandes non récentes'), - ('manage_perms', 'Gérer les permissions K-Fêt'), - ('manage_addcosts', 'Gérer les majorations'), - ('perform_commented_operations', 'Enregistrer des commandes avec commentaires'), - ('view_negs', 'Voir la liste des négatifs'), - ('order_to_inventory', "Générer un inventaire à partir d'une commande"), - ('edit_balance_account', "Modifier la balance d'un compte"), - ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), - ('special_add_account', "Créer un compte avec une balance initiale") - ) - - -class GenericTeamToken(models.Model): - token = models.CharField(max_length = 50, unique = True) diff --git a/kfet/open/__init__.py b/kfet/open/__init__.py new file mode 100644 index 00000000..fb88af65 --- /dev/null +++ b/kfet/open/__init__.py @@ -0,0 +1 @@ +from .open import OpenKfet, kfet_open # noqa diff --git a/kfet/open/consumers.py b/kfet/open/consumers.py new file mode 100644 index 00000000..b28a4664 --- /dev/null +++ b/kfet/open/consumers.py @@ -0,0 +1,25 @@ +from ..decorators import kfet_is_team +from ..utils import DjangoJsonWebsocketConsumer, PermConsumerMixin + +from .open import kfet_open + + +class OpenKfetConsumer(PermConsumerMixin, DjangoJsonWebsocketConsumer): + """Consumer for K-Fêt Open. + + WS groups: + kfet.open.base: Only carries the values visible for all users. + kfet.open.team: Carries all values (raw status...). + + """ + + def connection_groups(self, user, **kwargs): + """Select which group the user should be connected.""" + if kfet_is_team(user): + return ['kfet.open.team'] + return ['kfet.open.base'] + + def connect(self, message, *args, **kwargs): + """Send current status on connect.""" + super().connect(message, *args, **kwargs) + self.send(kfet_open.export(message.user)) diff --git a/kfet/open/open.py b/kfet/open/open.py new file mode 100644 index 00000000..82d6217a --- /dev/null +++ b/kfet/open/open.py @@ -0,0 +1,109 @@ +from datetime import timedelta + +from django.utils import timezone + +from ..decorators import kfet_is_team +from ..utils import CachedMixin + + +class OpenKfet(CachedMixin, object): + """Manage "open" status of a place. + + Stores raw data (e.g. sent by raspberry), and user-set values + (as force_close). + Setting differents `cache_prefix` allows different places management. + Current state persists through cache. + + """ + # status is unknown after this duration + time_unknown = timedelta(minutes=15) + + # status + OPENED = 'opened' + CLOSED = 'closed' + UNKNOWN = 'unknown' + # admin status + FAKE_CLOSED = 'fake_closed' + + # cached attributes config + cached = { + '_raw_open': False, + '_last_update': None, + 'force_close': False, + } + cache_prefix = 'kfetopen' + + @property + def raw_open(self): + """Defined as property to update `last_update` on `raw_open` update.""" + return self._raw_open + + @raw_open.setter + def raw_open(self, value): + self._last_update = timezone.now() + self._raw_open = value + + @property + def last_update(self): + """Prevent `last_update` to be set.""" + return self._last_update + + @property + def is_open(self): + """Take into account force_close.""" + return False if self.force_close else self.raw_open + + def status(self): + if (self.last_update is None or + timezone.now() - self.last_update >= self.time_unknown): + return self.UNKNOWN + return self.OPENED if self.is_open else self.CLOSED + + def admin_status(self, status=None): + if status is None: + status = self.status() + if status == self.CLOSED and self.raw_open: + return self.FAKE_CLOSED + return status + + def _export(self): + """Export internal state. + + Used by WS initialization and updates. + + Returns: + (tuple): (base, team) + - team for team users. + - base for others. + + """ + status = self.status() + base = { + 'status': status, + } + restrict = { + 'admin_status': self.admin_status(status), + 'force_close': self.force_close, + } + return base, dict(base, **restrict) + + def export(self, user): + """Export internal state for a given user. + + Returns: + (dict): Internal state. Only variables visible for the user are + exported, according to its permissions. + + """ + base, team = self._export() + return team if kfet_is_team(user) else base + + def send_ws(self): + """Send internal state to websocket channels.""" + from .consumers import OpenKfetConsumer + base, team = self._export() + OpenKfetConsumer.group_send('kfet.open.base', base) + OpenKfetConsumer.group_send('kfet.open.team', team) + + +kfet_open = OpenKfet() diff --git a/kfet/open/routing.py b/kfet/open/routing.py new file mode 100644 index 00000000..681bfab2 --- /dev/null +++ b/kfet/open/routing.py @@ -0,0 +1,8 @@ +from channels.routing import route_class + +from . import consumers + + +routing = [ + route_class(consumers.OpenKfetConsumer) +] diff --git a/kfet/open/static/kfetopen/kfet-open.css b/kfet/open/static/kfetopen/kfet-open.css new file mode 100644 index 00000000..a7068626 --- /dev/null +++ b/kfet/open/static/kfetopen/kfet-open.css @@ -0,0 +1,69 @@ +.kfetopen-st-opened .bullet { background: #73C252; } +.kfetopen-st-closed .bullet { background: #B42B26; } +.kfetopen-st-unknown .bullet { background: #D4BE4C; } +.kfetopen-st-fake_closed .bullet { + background: repeating-linear-gradient( + 45deg, + #73C252, #73C252 5px, #B42B26 5px, #B42B26 10px + ); +} + +.kfetopen { + float: left; +} + +.kfetopen .base { + height: 50px; + max-width: 16px; + + margin-left: 5px; + margin-right: 5px; + + display: flex; + flex-wrap: wrap; + align-content: center; + align-items: center; + justify-content: center; +} + +.kfetopen .details { + margin: 0; + padding: 10px !important; + min-width: 200px; + font-family: "Roboto Slab"; + font-size: 16px; + color: black; +} + +.kfetopen .bullet { + width: 10px; + height: 10px; + border-radius: 50%; + transition: background 0.15s; + margin: 3px; +} + +.kfetopen .warning { + display: none; +} + +@media (min-width: 576px) { + .kfetopen .base { + max-width: none; + margin-left: 15px; + margin-right: 15px; + } + + .kfetopen .warning { + margin-left: 15px; + } +} + +.kfetopen .status-text { + text-transform: uppercase; +} + +.kfetopen .force-close-btn { + width: 100%; + margin-top: 5px; +} diff --git a/kfet/open/static/kfetopen/kfet-open.js b/kfet/open/static/kfetopen/kfet-open.js new file mode 100644 index 00000000..74f18d8a --- /dev/null +++ b/kfet/open/static/kfetopen/kfet-open.js @@ -0,0 +1,113 @@ +var OpenWS = new KfetWebsocket({ + relative_url: "open/" +}); + +var OpenKfet = function(force_close_url, admin) { + this.force_close_url = force_close_url; + this.admin = admin; + + this.status = this.UNKNOWN; + this.dom = { + status_text: $('.kfetopen .status-text'), + force_close_btn: $('.kfetopen .force-close-btn'), + warning: $('.kfetopen .warning') + }, + + this.dom.force_close_btn.click( () => this.toggle_force_close() ); + setInterval( () => this.refresh(), this.refresh_interval * 1000); + OpenWS.add_handler( data => this.refresh(data) ); + +}; + +OpenKfet.prototype = { + // Status is unknown after . minutes without update. + time_unknown: 15, + // Maximum interval (seconds) between two UI refresh. + refresh_interval: 20, + + // Prefix for classes describing place status. + class_prefix: 'kfetopen-st-', + // Set status-classes on this dom element. + target: 'body', + + // Status + OPENED: "opened", + CLOSED: "closed", + UNKNOWN: "unknown", + + // Admin status + FAKE_CLOSED: "fake_closed", + + // Display values + status_text: { + opened: "ouverte", + closed: "fermée", + unknown: "_____" + }, + force_text: { + activate: "Fermer manuellement", + deactivate: "Réouvrir la K-Fêt" + }, + + get is_recent() { + return this.last_update && moment().diff(this.last_update, 'minute') <= this.time_unknown; + }, + + refresh: function(data) { + if (data) { + $.extend(this, data); + this.last_update = moment(); + } + if (!this.is_recent) + this.status = this.UNKNOWN; + this.refresh_dom(); + }, + + refresh_dom: function() { + let status = this.status; + this.clear_class(); + + this.add_class(status); + this.dom.status_text.html(this.status_text[status]); + + // admin specific + if (this.admin) { + this.add_class(this.admin_status); + if (this.force_close) { + this.dom.warning.show().addClass('in'); + this.dom.force_close_btn.html(this.force_text['deactivate']); + } else { + this.dom.warning.removeClass('in').hide(); + this.dom.force_close_btn.html(this.force_text['activate']); + } + } + }, + + toggle_force_close: function(password) { + $.post({ + url: this.force_close_url, + data: {force_close: !this.force_close}, + beforeSend: function ($xhr) { + $xhr.setRequestHeader("X-CSRFToken", csrftoken); + if (password !== undefined) + $xhr.setRequestHeader("KFetPassword", password); + } + }) + .fail(function($xhr) { + switch ($xhr.status) { + case 403: + requestAuth({'errors': {}}, this.toggle_force_close); + break; + } + }); + }, + + clear_class: function() { + let re = new RegExp('(^|\\s)' + this.class_prefix + '\\S+', 'g'); + $(this.target).attr('class', (i, c) => c ? c.replace(re, '') : ''); + }, + + add_class: function(status) { + $(this.target).addClass(this.class_prefix + status); + } +}; diff --git a/kfet/open/templates/kfetopen/init.html b/kfet/open/templates/kfetopen/init.html new file mode 100644 index 00000000..3834b32a --- /dev/null +++ b/kfet/open/templates/kfetopen/init.html @@ -0,0 +1,13 @@ +{% load static %} + + + + + diff --git a/kfet/open/tests.py b/kfet/open/tests.py new file mode 100644 index 00000000..476eb6c0 --- /dev/null +++ b/kfet/open/tests.py @@ -0,0 +1,334 @@ +import json +from datetime import timedelta +from unittest import mock + +from django.contrib.auth.models import AnonymousUser, Permission, User +from django.test import Client +from django.utils import timezone + +from channels.channel import Group +from channels.test import ChannelTestCase, WSClient + +from . import kfet_open, OpenKfet +from .consumers import OpenKfetConsumer + + +class OpenKfetTest(ChannelTestCase): + """OpenKfet object unit-tests suite.""" + + def setUp(self): + self.kfet_open = OpenKfet() + + def tearDown(self): + self.kfet_open.clear_cache() + + def test_defaults(self): + """Default values.""" + self.assertFalse(self.kfet_open.raw_open) + self.assertIsNone(self.kfet_open.last_update) + self.assertFalse(self.kfet_open.force_close) + self.assertFalse(self.kfet_open.is_open) + + def test_raw_open(self): + """Get and set raw_open; last_update is renewed.""" + for raw_open in [True, False]: + prev_update = self.kfet_open.last_update + self.kfet_open.raw_open = raw_open + self.assertEqual(raw_open, self.kfet_open.raw_open) + self.assertNotEqual(prev_update, self.kfet_open.last_update) + + def test_force_close(self): + """Get and set force_close.""" + for force_close in [True, False]: + self.kfet_open.force_close = force_close + self.assertEqual(force_close, self.kfet_open.force_close) + + def test_is_open(self): + """If force_close is disabled, is_open is raw_open.""" + self.kfet_open.force_close = False + for raw_open in [True, False]: + self.kfet_open.raw_open = raw_open + self.assertEqual(raw_open, self.kfet_open.is_open) + + def test_is_open_force_close(self): + """If force_close is enabled, is_open is False.""" + self.kfet_open.force_close = True + for raw_open in [True, False]: + self.kfet_open.raw_open = raw_open + self.assertFalse(self.kfet_open.is_open) + + def test_status(self): + # (raw_open, force_close, expected status, expected admin) + cases = [ + (False, False, OpenKfet.CLOSED, OpenKfet.CLOSED), + (False, True, OpenKfet.CLOSED, OpenKfet.CLOSED), + (True, False, OpenKfet.OPENED, OpenKfet.OPENED), + (True, True, OpenKfet.CLOSED, OpenKfet.FAKE_CLOSED), + ] + for raw_open, force_close, exp_stat, exp_adm_stat in cases: + self.kfet_open.raw_open = raw_open + self.kfet_open.force_close = force_close + self.assertEqual(exp_stat, self.kfet_open.status()) + self.assertEqual(exp_adm_stat, self.kfet_open.admin_status()) + + def test_status_unknown(self): + self.kfet_open.raw_open = True + self.kfet_open._last_update = timezone.now() - timedelta(days=30) + self.assertEqual(OpenKfet.UNKNOWN, self.kfet_open.status()) + + def test_export_user(self): + """Export is limited for an anonymous user.""" + export = self.kfet_open.export(AnonymousUser()) + self.assertSetEqual( + set(['status']), + set(export), + ) + + def test_export_team(self): + """Export all values for a team member.""" + user = User.objects.create_user('team', '', 'team') + user.user_permissions.add(Permission.objects.get(codename='is_team')) + export = self.kfet_open.export(user) + self.assertSetEqual( + set(['status', 'admin_status', 'force_close']), + set(export), + ) + + def test_send_ws(self): + Group('kfet.open.base').add('test.open.base') + Group('kfet.open.team').add('test.open.team') + + self.kfet_open.send_ws() + + recv_base = self.get_next_message('test.open.base', require=True) + base = json.loads(recv_base['text']) + self.assertSetEqual( + set(['status']), + set(base), + ) + + recv_admin = self.get_next_message('test.open.team', require=True) + admin = json.loads(recv_admin['text']) + self.assertSetEqual( + set(['status', 'admin_status', 'force_close']), + set(admin), + ) + + +class OpenKfetViewsTest(ChannelTestCase): + """OpenKfet views unit-tests suite.""" + + def setUp(self): + # Need this (and here) because of '.login' in setUp + patcher_messages = mock.patch('gestioncof.signals.messages') + patcher_messages.start() + self.addCleanup(patcher_messages.stop) + + # get some permissions + perms = { + 'kfet.is_team': Permission.objects.get(codename='is_team'), + 'kfet.can_force_close': Permission.objects.get(codename='can_force_close'), + } + + # authenticated user and its client + self.u = User.objects.create_user('user', '', 'user') + self.c = Client() + self.c.login(username='user', password='user') + + # team user and its clients + self.t = User.objects.create_user('team', '', 'team') + self.t.user_permissions.add(perms['kfet.is_team']) + self.c_t = Client() + self.c_t.login(username='team', password='team') + + # admin user and its client + self.a = User.objects.create_user('admin', '', 'admin') + self.a.user_permissions.add( + perms['kfet.is_team'], perms['kfet.can_force_close'], + ) + self.c_a = Client() + self.c_a.login(username='admin', password='admin') + + def tearDown(self): + kfet_open.clear_cache() + + def test_door(self): + """Edit raw_status.""" + for sent, expected in [(1, True), (0, False)]: + resp = Client().post('/k-fet/open/raw_open', { + 'raw_open': sent, + 'token': 'plop', + }) + self.assertEqual(200, resp.status_code) + self.assertEqual(expected, kfet_open.raw_open) + + def test_force_close(self): + """Edit force_close.""" + for sent, expected in [(1, True), (0, False)]: + resp = self.c_a.post('/k-fet/open/force_close', {'force_close': sent}) + self.assertEqual(200, resp.status_code) + self.assertEqual(expected, kfet_open.force_close) + + def test_force_close_forbidden(self): + """Can't edit force_close without kfet.can_force_close permission.""" + clients = [Client(), self.c, self.c_t] + for client in clients: + resp = client.post('/k-fet/open/force_close', {'force_close': 0}) + self.assertEqual(403, resp.status_code) + + +class OpenKfetConsumerTest(ChannelTestCase): + """OpenKfet consumer unit-tests suite.""" + + def test_standard_user(self): + """Lambda user is added to kfet.open.base group.""" + # setup anonymous client + c = WSClient() + + # connect + c.send_and_consume('websocket.connect', path='/ws/k-fet/open', + fail_on_none=True) + + # initialization data is replied on connection + self.assertIsNotNone(c.receive()) + + # client belongs to the 'kfet.open' group... + OpenKfetConsumer.group_send('kfet.open.base', {'test': 'plop'}) + self.assertEqual(c.receive(), {'test': 'plop'}) + + # ...but not to the 'kfet.open.admin' one + OpenKfetConsumer.group_send('kfet.open.team', {'test': 'plop'}) + self.assertIsNone(c.receive()) + + @mock.patch('gestioncof.signals.messages') + def test_team_user(self, mock_messages): + """Team user is added to kfet.open.team group.""" + # setup team user and its client + t = User.objects.create_user('team', '', 'team') + t.user_permissions.add( + Permission.objects.get(codename='is_team') + ) + c = WSClient() + c.force_login(t) + + # connect + c.send_and_consume('websocket.connect', path='/ws/k-fet/open', + fail_on_none=True) + + # initialization data is replied on connection + self.assertIsNotNone(c.receive()) + + # client belongs to the 'kfet.open.admin' group... + OpenKfetConsumer.group_send('kfet.open.team', {'test': 'plop'}) + self.assertEqual(c.receive(), {'test': 'plop'}) + + # ... but not to the 'kfet.open' one + OpenKfetConsumer.group_send('kfet.open.base', {'test': 'plop'}) + self.assertIsNone(c.receive()) + + +class OpenKfetScenarioTest(ChannelTestCase): + """OpenKfet functionnal tests suite.""" + + def setUp(self): + # Need this (and here) because of '.login' in setUp + patcher_messages = mock.patch('gestioncof.signals.messages') + patcher_messages.start() + self.addCleanup(patcher_messages.stop) + + # anonymous client (for views) + self.c = Client() + # anonymous client (for websockets) + self.c_ws = WSClient() + + # root user + self.r = User.objects.create_superuser('root', '', 'root') + # its client (for views) + self.r_c = Client() + self.r_c.login(username='root', password='root') + # its client (for websockets) + self.r_c_ws = WSClient() + self.r_c_ws.force_login(self.r) + + def tearDown(self): + kfet_open.clear_cache() + + def ws_connect(self, ws_client): + ws_client.send_and_consume( + 'websocket.connect', path='/ws/k-fet/open', + fail_on_none=True, + ) + return ws_client.receive(json=True) + + def test_scenario_0(self): + """Clients connect.""" + # test for anonymous user + msg = self.ws_connect(self.c_ws) + self.assertSetEqual( + set(['status']), + set(msg), + ) + + # test for root user + msg = self.ws_connect(self.r_c_ws) + self.assertSetEqual( + set(['status', 'admin_status', 'force_close']), + set(msg), + ) + + def test_scenario_1(self): + """Clients connect, door opens, enable force close.""" + self.ws_connect(self.c_ws) + self.ws_connect(self.r_c_ws) + + # door sent "I'm open!" + self.c.post('/k-fet/open/raw_open', { + 'raw_open': True, + 'token': 'plop', + }) + + # anonymous user agree + msg = self.c_ws.receive(json=True) + self.assertEqual(OpenKfet.OPENED, msg['status']) + + # root user too + msg = self.r_c_ws.receive(json=True) + self.assertEqual(OpenKfet.OPENED, msg['status']) + self.assertEqual(OpenKfet.OPENED, msg['admin_status']) + + # admin says "no it's closed" + self.r_c.post('/k-fet/open/force_close', {'force_close': True}) + + # so anonymous user see it's closed + msg = self.c_ws.receive(json=True) + self.assertEqual(OpenKfet.CLOSED, msg['status']) + + # root user too + msg = self.r_c_ws.receive(json=True) + self.assertEqual(OpenKfet.CLOSED, msg['status']) + # but root knows things + self.assertEqual(OpenKfet.FAKE_CLOSED, msg['admin_status']) + self.assertTrue(msg['force_close']) + + def test_scenario_2(self): + """Starting falsely closed, clients connect, disable force close.""" + kfet_open.raw_open = True + kfet_open.force_close = True + + msg = self.ws_connect(self.c_ws) + self.assertEqual(OpenKfet.CLOSED, msg['status']) + + msg = self.ws_connect(self.r_c_ws) + self.assertEqual(OpenKfet.CLOSED, msg['status']) + self.assertEqual(OpenKfet.FAKE_CLOSED, msg['admin_status']) + self.assertTrue(msg['force_close']) + + self.r_c.post('/k-fet/open/force_close', {'force_close': False}) + + msg = self.c_ws.receive(json=True) + self.assertEqual(OpenKfet.OPENED, msg['status']) + + msg = self.r_c_ws.receive(json=True) + self.assertEqual(OpenKfet.OPENED, msg['status']) + self.assertEqual(OpenKfet.OPENED, msg['admin_status']) + self.assertFalse(msg['force_close']) diff --git a/kfet/open/urls.py b/kfet/open/urls.py new file mode 100644 index 00000000..bd227b96 --- /dev/null +++ b/kfet/open/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'^raw_open$', views.raw_open, + name='kfet.open.edit_raw_open'), + url(r'^force_close$', views.force_close, + name='kfet.open.edit_force_close'), +] diff --git a/kfet/open/views.py b/kfet/open/views.py new file mode 100644 index 00000000..4f1efa5f --- /dev/null +++ b/kfet/open/views.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.contrib.auth.decorators import permission_required +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from .open import kfet_open + + +TRUE_STR = ['1', 'True', 'true'] + + +@csrf_exempt +@require_POST +def raw_open(request): + token = request.POST.get('token') + if token != settings.KFETOPEN_TOKEN: + raise PermissionDenied + raw_open = request.POST.get('raw_open') in TRUE_STR + kfet_open.raw_open = raw_open + kfet_open.send_ws() + return HttpResponse() + + +@permission_required('kfet.can_force_close', raise_exception=True) +@require_POST +def force_close(request): + force_close = request.POST.get('force_close') in TRUE_STR + kfet_open.force_close = force_close + kfet_open.send_ws() + return HttpResponse() diff --git a/kfet/routing.py b/kfet/routing.py index 5ea343cb..f1305d4b 100644 --- a/kfet/routing.py +++ b/kfet/routing.py @@ -1,12 +1,9 @@ -# -*- coding: utf-8 -*- +from channels.routing import include, route_class -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * +from . import consumers -from channels.routing import route, route_class -from kfet import consumers -channel_routing = [ - route_class(consumers.KPsul, path=r"^/ws/k-fet/k-psul/$"), +routing = [ + route_class(consumers.KPsul, path=r'^/k-psul/$'), + include('kfet.open.routing.routing', path=r'^/open'), ] diff --git a/kfet/signals.py b/kfet/signals.py deleted file mode 100644 index 3dd4d677..00000000 --- a/kfet/signals.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * - -from django.contrib import messages -from django.contrib.auth.signals import user_logged_in -from django.core.urlresolvers import reverse -from django.dispatch import receiver - -@receiver(user_logged_in) -def messages_on_login(sender, request, user, **kwargs): - if (not user.username == 'kfet_genericteam' - and user.has_perm('kfet.is_team')): - messages.info(request, 'Connexion en utilisateur partagé ?' % reverse('kfet.login.genericteam'), extra_tags='safe') diff --git a/kfet/static/kfet/css/base/buttons.css b/kfet/static/kfet/css/base/buttons.css new file mode 100644 index 00000000..e7498022 --- /dev/null +++ b/kfet/static/kfet/css/base/buttons.css @@ -0,0 +1,88 @@ +/* General ------------------------- */ + +.btn { + border: 0; + outline: none !important; + + transition: background-color, border, color, opacity; + transition-duration: 0.15s; + + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + font-family: "Roboto Slab"; +} + +.btn, .btn-lg, .btn-group-lg>.btn { + border-radius:0; +} + + +/* Default ------------------------- */ + +.btn-default { + background-color: transparent !important; + color: #555; +} + +.btn-default:hover, +.btn-default.focus, .btn-default:focus { + color: #c8102e; +} + +.btn-default[disabled]:hover, .btn-default.disabled:hover { + color: inherit !important; +} + + +/* Primary ------------------------- */ + +.btn-primary { + background-color:#c63b52; + color:#FFF; +} + +.btn-primary:hover, +.btn-primary.focus, .btn-primary:focus, +.btn-primary.active.focus, .btn-primary.active:focus, .btn-primary.active:hover, +.btn-primary:active.focus, .btn-primary:active:focus, .btn-primary:active:hover { + background-color:#c8102e; + color:#FFF; +} + + +/* Primary + White background ------ */ + +.btn-primary-w { + background: white; + color: black; +} + +.btn-primary-w:hover { + background: #c63b52; + color: white; +} + +.btn-primary-w.focus, .btn-primary-w:focus, +.btn-primary-w.active.focus, .btn-primary-w.active:focus, .btn-primary-w.active:hover, +.btn-primary-w:active.focus, .btn-primary-w:active:focus, .btn-primary-w:active:hover { + background: #c8102e; + color: white; +} + + +/* Nav ----------------------------- */ + +.btn-nav { + background-color: transparent !important; + color: inherit; + border-bottom: 1px solid #ddd; +} + +.btn-nav:hover, +.btn-nav.focus, .btn-nav:focus, +.btn-nav.active.focus, .btn-nav.active:focus, .btn-nav.active:hover, +.btn-nav:active.focus, .btn-nav:active:focus, .btn-nav:active:hover { + border-bottom: 1px solid #c8102e; +} diff --git a/kfet/static/kfet/css/base/fixed.css b/kfet/static/kfet/css/base/fixed.css new file mode 100644 index 00000000..d198c50f --- /dev/null +++ b/kfet/static/kfet/css/base/fixed.css @@ -0,0 +1,151 @@ +.fixed > * + * { + margin-top: 15px; +} + + +/* Aside --------------------------- */ + +/* Aside - Block */ + +aside { + background: white; + padding: 15px; +} + +aside > * + * { + margin-top: 15px; +} + +/* Aside - Misc */ + +aside .glyphicon-question-sign { + font-size: 0.8; +} + +aside h4 { + font-weight: bold; +} + +/* Aside - Heading */ + +aside .heading { + font-family: "Roboto Slab"; + font-size: 25px; + font-weight: bold; + line-height: 1.1; + text-align: center; +} + +aside .heading .big { + font-size: 2em; +} + +aside .heading .sub { + font-size: 0.7em; + font-weight: normal; +} + +@media (min-width: 992px) { + aside .heading { + font-size: 32px; + line-height: 1.3; + } +} + +/* Aside - Buttons */ + +aside .buttons { + margin-left: -15px; + margin-right: -15px; +} + +aside .buttons > * { + flex: 0 1 auto !important; +} + + +/* Aside - Text */ + +aside .text { + line-height: 1.3; + font-size: 14px; +} + +@media (min-width: 992px) { + aside .text { + line-height: 1.6; + font-size: 16px; + } +} + +aside .text ul { + margin-bottom: 0; +} + + +/* Buttons ------------------------- */ + +.fixed .buttons { + display: flex; + flex-flow: row wrap; + justify-content: center; + + text-align: center; +} + +.fixed .buttons > * { + flex: 0 1 auto; + overflow: hidden; +} + +.fixed .buttons > .solo { + flex: 1 100%; +} + +@media (min-width: 768px) { + .fixed .buttons > * { + flex: 1 auto; + } + + .fixed .buttons > .full > * { + width: 100%; + } +} + +.fixed .buttons .btn { + padding: 8px 12px; +} + +@media (min-width: 992px) { + .fixed .buttons .btn { + font-size: 16px; + } +} + + +/* Tabs ---------------------------- */ + +.fixed .tabs-buttons { + margin-bottom: -5px; +} + +.fixed .tabs-buttons > * { + margin-bottom: 5px; +} + +.fixed .tabs-buttons .glyphicon-chevron-right { + margin-left: 5px; + line-height: 1.4; + color: white; +} + +@media (min-width: 768px) { + .fixed .tabs-buttons { + text-align: right; + justify-content: flex-end; + } + + .fixed .tabs-buttons > * { + flex: 1 100%; + } +} diff --git a/kfet/static/kfet/css/base/footer.css b/kfet/static/kfet/css/base/footer.css new file mode 100644 index 00000000..abdf98ed --- /dev/null +++ b/kfet/static/kfet/css/base/footer.css @@ -0,0 +1,18 @@ +.footer { + line-height: 40px; + + background: #c63b52; + color: white; + + text-align: center; + font-size: 14px; + font-family: Roboto; +} + +.footer a { + color: inherit !important; +} + +.footer a:hover, .footer a:focus { + text-decoration: underline; +} diff --git a/kfet/static/kfet/css/base/main.css b/kfet/static/kfet/css/base/main.css new file mode 100644 index 00000000..2ebc90d8 --- /dev/null +++ b/kfet/static/kfet/css/base/main.css @@ -0,0 +1,138 @@ +/* Global layout ------------------- */ + +.main-col, .fixed-col { + padding: 0 0 15px; +} + +@media (min-width: 768px) { + .fixed-col { + position: sticky; + top: 35px; + padding-top: 15px; + } + + .fixed-col + .main-col { + padding: 15px 0 15px 15px; + } +} + +@media (min-width: 992px) { + .main-col { + padding: 15px; + } +} + +.main-col-mult { + column-gap: 45px; +} + +.main-bg { + background: white; +} + +.main-padding { + padding: 15px; +} + +@media (min-width: 768px) { + .main-padding { + padding: 30px; + } +} + + +/* Section ------------------------- */ + +section { + margin-bottom: 15px; + position:relative; +} + +section:last-child { + margin-bottom: 0; +} + + +/* Section - Elements -------------- */ + +section > * { + background: white; + padding: 15px; +} + +section > .full, +section > table, +section > .table-responsive { + padding: 0 !important; + margin-left: 0 !important; + margin-right: 0 !important; +} + +section .full { + margin-left: -15px; + margin-right: -15px; +} + +@media (min-width: 992px) { + section > * { + padding: 30px; + } + + section .full { + margin-left: -30px; + margin-right: -30px; + } +} + +section .row > div:last-child { + margin-bottom: 0 !important; +} + +@media (max-width: 768px) { + section .row > div { + margin-bottom: 10px; + } +} + +@media (max-width: 1200px) { + section .row > div { + margin-bottom: 20px; + } +} + +section ul ul { + padding-left: 30px; +} + +/* Titles & Heading */ + +section h2, +section .heading { + background: transparent; + margin: 20px 15px 15px; + padding: 0; + border-bottom: 3px solid #c8102e; + font-family: "Roboto Slab"; + font-size: 40px; + line-height: 1.1; +} + +section h3 { + border-bottom: 2px solid #c8102e; + margin: 0 0 10px; + padding: 10px 0 10px; + font-size: 25px; + font-weight: bold; +} + +section .heading .buttons { + opacity: 0.7; + top: 10px; + float: right; +} + +section h2:first-child, +section h3:first-child { + padding-top: 0; + margin-top: 0; +} diff --git a/kfet/static/kfet/css/base/messages.css b/kfet/static/kfet/css/base/messages.css new file mode 100644 index 00000000..268f514d --- /dev/null +++ b/kfet/static/kfet/css/base/messages.css @@ -0,0 +1,36 @@ +.messages .alert { + padding:10px 15px; + margin:0; + border:0; + border-radius:0; +} + +.messages .alert:last-child { + margin-bottom: 15px; +} + +.messages .alert .close { + top:0; + right:0; +} + +.messages .alert-info { + color:inherit; + background-color:#ccc; +} + +.messages .alert-error { + color: white; + background-color: #c63b52; +} + +.messages .alert-success { + color: white; + background: #3d9947; +} + +.messages a { + font-weight: bold; + text-decoration: none; +} + diff --git a/kfet/static/kfet/css/base/misc.css b/kfet/static/kfet/css/base/misc.css new file mode 100644 index 00000000..fab1a33a --- /dev/null +++ b/kfet/static/kfet/css/base/misc.css @@ -0,0 +1,122 @@ +/* General ------------------------- */ + +body { + margin-top:50px; + font-family:Roboto; + background:#ddd; +} + +.glyphicon + span, span + .glyphicon { + margin-left: 10px; +} + +/* Titles */ + +h1,h2,h3,h4,h5,h6 { + font-family:"Roboto Slab"; +} + +/* Links */ + +a { + color:#C8202E; +} + +a:focus, a:hover { + color:#C8102E; +} + +/* Inputs */ + +:focus { + outline:none; +} + +textarea { + font-family:'Roboto Mono'; + border-radius:0 !important; +} + +/* Lists */ + +ul, ol { + padding-left: 30px; +} + +ul { + list-style-type: square; +} + +/* Tables */ + +.table { + margin-bottom:0; + border-bottom:1px solid #ddd; + width:100%; + background-color: #FFF; +} + +.table td { + vertical-align:middle !important; +} + +.table td.no-padding { + padding:0; +} + +.table thead { + background:#c8102e; + color:#fff; + font-weight:bold; + font-size:16px; +} + +.table thead td { + padding:8px !important; +} + +.table thead .sm-padding { + padding:3px !important; +} + +.table tr.section { + background: #c63b52 !important; + color:#fff; + font-weight:bold; +} + +.table tr.section td { + border-top:0; + font-size:16px; + padding:8px 30px; +} + +.table tr.more td { + padding: 0; +} + +.table-responsive { + border: 0; + margin-bottom: 0; +} + +/* Toggle on hover ----------------- */ + +.toggle:not(:hover) .hover { + display: none; +} + +.toggle:hover .base { + display: none; +} + +/* Spinning animation -------------- */ + +.glyphicon.spinning { + animation: spin 1s infinite linear; +} + +@keyframes spin { + from { transform: scale(1) rotate(0deg); } + to { transform: scale(1) rotate(360deg); } +} diff --git a/kfet/static/kfet/css/base/nav.css b/kfet/static/kfet/css/base/nav.css new file mode 100644 index 00000000..0efc2873 --- /dev/null +++ b/kfet/static/kfet/css/base/nav.css @@ -0,0 +1,151 @@ +.navbar { + background: #000; + color: #DDD; + border: 0; + font-family: Roboto; +} + +.navbar .navbar-header { + float: left; + display: none; + margin-left: -15px; + margin-right: 0; +} + +.navbar .navbar-brand { + padding: 3px 0; + margin: 0 15px !important; +} + +.navbar .navbar-brand img { + height: 44px; +} + +.navbar .navbar-toggle { + border: 0; + border-radius: 0; + padding: 18px 15px; + margin: 0; + min-width: auto; +} + +.navbar .navbar-toggle .icon-bar { + background: #fff; +} + +.navbar-nav { + font-size: 14px; + margin: 0 0 0 -15px; + float: left; +} + +@media (min-width: 460px) { + .navbar .navbar-header { + display: block; + } + + .navbar-nav { + margin-left: 0; + } + + .navbar-nav .nav-pages.dropdown .dropdown-menu > li:first-child { + display: none; + } +} + +.navbar-right { + float: right !important; + margin: 0 -15px 0 0; +} + +.navbar-nav a { + transition: background-color, box-shadow, color; + transition-duration: 0.15s; +} + +.navbar-nav > li { + float: left; + text-align: center; +} + +.navbar-nav > li > a { + min-width: 50px; + padding: 15px 10px; + color: #FFF; +} + +.navbar-nav > .divider { + height: 1px; + background: rgba(255, 255, 255, 0.1); +} + +@media (min-width: 1200px) { + .navbar-nav > li > a { + padding-left: 15px; + padding-right: 15px; + } +} + +.navbar-nav > li > a:hover, .navbar-nav > li > a:focus, +.nav .open > a:hover, .nav .open > a:focus, +.navbar-nav > li.active > a, +.navbar-nav .dropdown:hover > a, .navbar-nav .dropdown:focus > a { + background-color: #C8102E; + color: #FFF; + box-shadow: inset 0 3px 3px -4px #000; +} + +.navbar-nav .dropdown .dropdown-menu { + padding: 0; + border: 0; + border-radius: 0; + background-color: #FFF; + font-size: 14px; + + /* override max-width: 767px of bs */ + position: absolute; + float: left; + box-shadow: 0 6px 12px rgba(0,0,0,.175); +} + +.navbar-nav .dropdown .dropdown-menu > li > a { + padding: 8px 10px; + line-height: inherit; + color: #000; +} + +.navbar-nav .dropdown .dropdown-menu > li > a:hover, +.navbar-nav .dropdown .dropdown-menu > li > a:focus { + color: #c8102e; + background-color: transparent; +} + +.navbar-nav .dropdown .dropdown-menu .divider { + margin: 0; +} + +.navbar-nav .dropdown .dropdown-menu { + display: block; + visibility: hidden; + opacity: 0; + transition: opacity 0.15s; +} + +.navbar-nav .dropdown:hover > .dropdown-menu, +.navbar-nav .dropdown:focus > .dropdown-menu, +.navbar-nav .dropdown.open > .dropdown-menu { + visibility: visible; + opacity: 1; +} + +@media (min-width: 992px) { + .navbar-nav .dropdown .dropdown-menu > li > a { + padding-left: 20px; + padding-right: 20px; + } +} + +.nav-app .dropdown-menu { + right: 0; + left: auto; +} diff --git a/kfet/static/kfet/css/history.css b/kfet/static/kfet/css/history.css index 976f5782..9cd4cd28 100644 --- a/kfet/static/kfet/css/history.css +++ b/kfet/static/kfet/css/history.css @@ -9,17 +9,21 @@ #history .day { height:40px; line-height:40px; - background-color:#c8102e; + background-color:rgba(200,16,46,1); color:#fff; padding-left:20px; + font-family:"Roboto Slab"; font-size:16px; font-weight:bold; + position:sticky; + top:50px; + z-index:10; } #history .opegroup { height:30px; line-height:30px; - background-color:rgba(200,16,46,0.85); + background-color: #c63b52; color:#fff; font-weight:bold; padding-left:20px; diff --git a/kfet/static/kfet/css/home.css b/kfet/static/kfet/css/home.css deleted file mode 100644 index 718159c3..00000000 --- a/kfet/static/kfet/css/home.css +++ /dev/null @@ -1,54 +0,0 @@ -ul.carte { - width: 100%; - list-style-type: none; - padding-left: 15px; - padding-right: 15px; - display: inline-block; - *display: inline; - zoom: 1; - position: relative; - clip: auto; - overflow: hidden; -} -/* -ul.carte > li { - border-style: none none solid none; - border-width: 1px; - border-color: #DDD; -} -*/ -li.carte-line { - position: relative; - text-align: right; - white-space: nowrap; -} -.filler { - position: absolute; - left: 0; - right: 0; - border-bottom: 3px dotted #333; - height: 70%; -} -.carte-label { - background: white; - float: left; - padding-right: 4px; - position: relative; - max-width: calc(100% - 40px); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.carte-ukf { - background: white; - padding-left: 4px; - position: relative; -} - - -.unbreakable.carte-inverted .carte-ukf, -.unbreakable.carte-inverted .carte-label, -.unbreakable.carte-inverted { - background: #FFDBC7; -} diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index 0244a57b..fdb86aff 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -1,264 +1,127 @@ -@import url("nav.css"); +/* Libs */ +@import url("libs/columns.css"); + +/* Libs customizations */ +@import url("libs/jconfirm-kfet.css"); +@import url("libs/jquery-tablesorter-kfet.css"); +@import url("libs/multiple-select-kfet.css"); + +/* Base */ +@import url("base/misc.css"); +@import url("base/buttons.css"); + +/* Blocks */ +@import url("base/main.css"); +@import url("base/nav.css"); +@import url("base/messages.css"); +@import url("base/fixed.css"); +@import url("base/footer.css"); + +/* Components */ @import url("kpsul.css"); -@import url("jconfirm-kfet.css"); @import url("history.css"); -body { - margin-top:50px; - font-family:Roboto; - background:#ddd; + + +.header { + padding: 15px 20px; + + background-color: rgba(200,16,46,1); + color: #FFF; } -h1,h2,h3,h4,h5,h6 { - font-family:Oswald; -} - -a { - color:#333; -} - -a:focus, a:hover { - color:#C8102E; -} - -:focus { - outline:none; -} - -textarea { - font-family:'Roboto Mono'; - border-radius:0 !important; -} - -.table { - margin-bottom:0; - border-bottom:1px solid #ddd; -} - -.table { - width:100%; -} - -.table td { - vertical-align:middle !important; -} - -.table td.no-padding { - padding:0; -} - -.table thead { - background:#c8102e; - color:#fff; - font-weight:bold; - font-size:16px; -} - -.table thead td { - padding:8px !important; -} - -.table tr.section { - background:rgba(200,16,46,0.9); - color:#fff; - font-weight:bold; -} - -.table tr.section td { - border-top:0; - font-size:16px; - padding:8px 30px; -} - -.btn, .btn-lg, .btn-group-lg>.btn { - border-radius:0; -} - -.btn-primary { - font-family:Oswald; - background-color:rgba(200,16,46,0.9); - color:#FFF; - border:0; -} - -.btn-primary:hover, .btn-primary.focus, .btn-primary:focus { - background-color:#000; - color:#FFF; -} - -.buttons .nav-pills > li > a { - border-radius:0; - border:1px solid rgba(200,16,46,0.9); -} - -.buttons .nav-pills > li.active > a { - background-color:rgba(200,16,46,0.9); - background-clip:padding-box; -} - -.row-page-header { - background-color:rgba(200,16,46,1); - color:#FFF; - border-bottom:3px solid #000; -} - -.page-header { - border:0; - padding:0; - margin:15px 20px; - text-transform:uppercase; - font-weight:bold; +.header h1 { + padding: 0; + margin: 0; + font-weight: bold; } .nopadding { padding: 0 !important; } -.panel-md-margin{ - background-color: white; - overflow:hidden; - padding-left: 15px; - padding-right: 15px; - padding-bottom: 15px; - padding-top: 1px; -} - -@media (min-width: 992px) { - .panel-md-margin{ - margin:8px; - background-color: white; - } -} - -.col-content-left, .col-content-right { - padding:0; -} - -.content-left-top { - background:#fff; - padding:10px 30px; -} - -.content-left .buttons { - background:#fff; -} - -.content-left .buttons .btn { - display:block; -} - -.content-left-top.frozen-account { - background:#000FBA; +.frozen-account { + background:#5072e0; color:#fff; } -.content-left .block { - padding-top:25px; + +.main .table a:not(.btn) { + color: inherit; } -.content-left .block .line { - font-size:16px; - line-height:30px; +.main .table a:not(.btn):focus , +.main .table a:not(.btn):hover { + color: #C81022; } -.content-left .line.line-big { - font-family:Oswald; - font-size:60px; - font-weight:bold; - text-align:center; +.table thead .glyphicon { + font-size: 12px; + opacity: 0.8; } -.content-left .line.line-bigsub { - font-size:25px; - font-weight:bold; - text-align:center; -} - -.content-left .line.balance { - font-size:45px; - text-align:center; -} - -.content-right { - margin:0 15px; -} - -.content-right-block { - padding-bottom:5px; - position:relative; -} - -.content-right-block:last-child { - padding-bottom:15px; -} - -.content-right-block > div:not(.buttons-title) { - background:#fff; -} - -.content-right-block-transparent > div:not(.buttons-title) { - background-color: transparent; -} - -.content-right-block .buttons-title { - position:absolute; - top:8px; - right:20px; -} - -.content-right-block > div.row { - margin:0; -} - -.content-right-block h2 { - margin:20px 20px 15px; - padding-bottom:5px; - border-bottom:3px solid #c8102e; - font-size:40px; -} - -.content-right-block h3 { - border-bottom: 1px solid #c8102e; - margin: 20px 15px 15px; - padding-bottom: 10px; - padding-left: 20px; - font-size:25px; -} /* * Pages tableaux seuls */ -.content-center > div { - background:#fff; -} -.content-center tbody tr:not(.section) td { - padding:0px 5px !important; -} - -.content-center .table .form-control { +.table .form-control { padding: 1px 12px ; height:28px; margin:3px 0px; + background: transparent; } - .content-center .auth-form { - margin:15px; + +.table .form-control[disabled], .table .form-control[readonly] { + background: #f5f5f5; +} + +.table-condensed-input tbody tr:not(.section) td { + padding:0px 5px; +} + +.table-condensed input.form-control { + margin: 0 !important; + border-top: 0; + border-bottom: 0; + border-radius: 0; +} + +.table td.small-width { + /* Header still extends the width of the column, but it will be minimal. */ + width: 30px; +} + +.auth-form { + padding: 15px 0; + background: #d86c7e; + color: white; +} + +.auth-form.form-horizontal { + padding: 0; + margin: 0; +} + +.auth-form .form-group { + margin-bottom: 0; +} + +.auth-form input { + box-shadow: none !important; + background: transparent; + color: white; + border: 0 !important; + border-radius: 0; + border-bottom: 1px solid white !important; } /* * Pages formulaires seuls */ -.form-only .content-form { - margin:15px; - - background:#fff; - - padding:15px; -} - -.form-only .account_create #id_trigramme { +.account_create #id_trigramme { display:block; width:200px; height:80px; @@ -320,38 +183,48 @@ textarea { padding:5px 20px; } -/* - * Messages +/* Account autocomplete window */ + +#account_results ul { + list-style-type:none; + background:rgba(255,255,255,0.9); + padding:0; +} + +#account_results li { + display:block; + padding:5px 20px; + height:100%; + width:100%; +} + +#account_results .hilight { + background:rgba(200,16,46,0.9); + color:#fff; + text-decoration:none; +} + +/** + * Stats (graphs) */ -.messages .alert { - padding:10px 15px; - margin:0; - border:0; - border-radius:0; +.stat-nav { + margin-bottom: 10px; + font-family: Roboto; } -.messages .alert-dismissible { - padding-right:35px; +.stat-nav li { + float: left; } -.messages .alert .close { - top:0; - right:0; +.stat-nav a { + opacity: 0.6; + font-family: Roboto; } -.messages .alert-info { - color:inherit; - background-color:#ccc; -} - -.messages .alert-error { - color:inherit; - background-color:rgba(200,16,46,0.2); -} - -.messages .alert-success { - color:#333; +.stat-nav a:hover, +.stat-nav a.focus, .stat-nav a:focus { + opacity: 1; } /* @@ -374,7 +247,7 @@ textarea { margin-top:30px; padding-top:1px; padding-bottom:15px; - background:rgba(51,51,51,0.7); + background:rgba(51,51,51,0.9); color:#fff; } @@ -411,171 +284,56 @@ thead .tooltip { height: 100px; } -/* - * Responsive Columns - */ - -.unbreakable { - display:inline-block; - width: 100%; -} - - -.column-row { - padding: 15px 20px; -} - -.column-xs-1, -.column-sm-1, -.column-md-1, -.column-lg-1, -.column-xs-2, -.column-sm-2, -.column-md-2, -.column-lg-2, -.column-xs-3, -.column-sm-3, -.column-md-3, -.column-lg-3, -.column-xs-4, -.column-sm-4, -.column-md-4, -.column-lg-4, -.column-xs-5, -.column-sm-5, -.column-md-5, -.column-lg-5 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; -} - - -.column-xs-1 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; -} -.column-xs-2 { - -webkit-column-count: 2; /* Chrome, Safari, Opera */ - -moz-column-count: 2; /* Firefox */ - column-count: 2; -} -.column-xs-3 { - -webkit-column-count: 3; /* Chrome, Safari, Opera */ - -moz-column-count: 3; /* Firefox */ - column-count: 3; -} -.column-xs-4 { - -webkit-column-count: 4; /* Chrome, Safari, Opera */ - -moz-column-count: 4; /* Firefox */ - column-count: 4; -} -.column-xs-5 { - -webkit-column-count: 5; /* Chrome, Safari, Opera */ - -moz-column-count: 5; /* Firefox */ - column-count: 5; -} - -@media (min-width: 576px) { - .column-sm-1 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; - } - .column-sm-2 { - -webkit-column-count: 2; /* Chrome, Safari, Opera */ - -moz-column-count: 2; /* Firefox */ - column-count: 2; - } - .column-sm-3 { - -webkit-column-count: 3; /* Chrome, Safari, Opera */ - -moz-column-count: 3; /* Firefox */ - column-count: 3; - } - .column-sm-4 { - -webkit-column-count: 4; /* Chrome, Safari, Opera */ - -moz-column-count: 4; /* Firefox */ - column-count: 4; - } - .column-sm-5 { - -webkit-column-count: 5; /* Chrome, Safari, Opera */ - -moz-column-count: 5; /* Firefox */ - column-count: 5; - } -} - -@media (min-width: 768px) { - .column-md-1 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; - } - .column-md-2 { - -webkit-column-count: 2; /* Chrome, Safari, Opera */ - -moz-column-count: 2; /* Firefox */ - column-count: 2; - } - .column-md-3 { - -webkit-column-count: 3; /* Chrome, Safari, Opera */ - -moz-column-count: 3; /* Firefox */ - column-count: 3; - } - .column-md-4 { - -webkit-column-count: 4; /* Chrome, Safari, Opera */ - -moz-column-count: 4; /* Firefox */ - column-count: 4; - } - .column-md-5 { - -webkit-column-count: 5; /* Chrome, Safari, Opera */ - -moz-column-count: 5; /* Firefox */ - column-count: 5; - } -} - -@media (min-width: 992px) { - .column-lg-1 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; - } - .column-lg-2 { - -webkit-column-count: 2; /* Chrome, Safari, Opera */ - -moz-column-count: 2; /* Firefox */ - column-count: 2; - } - .column-lg-3 { - -webkit-column-count: 3; /* Chrome, Safari, Opera */ - -moz-column-count: 3; /* Firefox */ - column-count: 3; - } - .column-lg-4 { - -webkit-column-count: 4; /* Chrome, Safari, Opera */ - -moz-column-count: 4; /* Firefox */ - column-count: 4; - } - .column-lg-5 { - -webkit-column-count: 5; /* Chrome, Safari, Opera */ - -moz-column-count: 5; /* Firefox */ - column-count: 5; - } -} - -.help-block { - padding-top: 15px; -} /* Inventaires */ +.table-condensed-input input[type=number] { + text-align: center; +} + .inventory_modified { background:rgba(236,100,0,0.15); } .stock_diff { padding-left: 5px; - color:#C8102E; + color:#C8102E; } .inventory_update { - display:none; + display: none; + width: 50px; + margin: 0 auto; +} + + +/* Checkbox select multiple */ + +.checkbox-select-multiple label { + font-weight: normal; + margin-bottom: 0; +} + +/* Statement creation */ + +.statement-create-summary table { + margin: 0 auto; +} + +.statement-create-summary tr td { + text-align: right; +} + +.statement-create-summary tr td:first-child { + padding-right: 15px; + font-weight: bold; +} + +.statement-create-summary tr td:last-child { + width: 80px; +} + +#detail_taken table td, +#detail_balance table td { + padding: 0; } diff --git a/kfet/static/kfet/css/kpsul.css b/kfet/static/kfet/css/kpsul.css index ba88e433..6ae9ebc4 100644 --- a/kfet/static/kfet/css/kpsul.css +++ b/kfet/static/kfet/css/kpsul.css @@ -18,6 +18,17 @@ input[type=number]::-webkit-outer-spin-button { 100% { background: yellow; } } +/* Announcements banner */ + +#banner { + background-color: #d86b01; + width: 100%; + text-align: center; + padding: 10px; + color: white; + font-size: larger; +} + /* * Top row */ @@ -143,9 +154,10 @@ input[type=number]::-webkit-outer-spin-button { height:50px; padding:0 15px; - background:#c8102e; + background:rgba(200,16,46,1); color:#fff; + font-family:"Roboto Slab"; font-weight:bold; font-size:18px; } @@ -226,24 +238,17 @@ input[type=number]::-webkit-outer-spin-button { height:40px; } -#special_operations button { - height:100%; - width:25%; +#special_operations .btn { + height:40px; - float:left; - - background:#c8102e; - color:#FFF; - - font-size:18px; + font-size:15px; font-weight:bold; + + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; } -#special_operations button:focus, #special_operations button:hover { - outline:none; - background:#000; - color:#fff; -} /* Article autocomplete */ @@ -256,15 +261,14 @@ input[type=number]::-webkit-outer-spin-button { height:100%; float:left; border:0; - border-right:1px solid #c8102e; - border-bottom:1px solid #c8102e; border-radius:0; + border-bottom: 1px solid rgba(200,16,46,0.9); font-size:16px; font-weight:bold; } -#article_selection input+input #article_selection input+span { - border-right:0; +#article_selection input:first-child { + border-right: 1px dashed rgba(200,16,46,0.9); } #article_autocomplete { @@ -313,16 +317,22 @@ input[type=number]::-webkit-outer-spin-button { font-size:14px; } -#articles_data table tr.article>td:first-child { +#articles_data table tr.article td:first-child { padding-left:10px; } +#articles_data table tr.article td + td { + padding-right:10px; + text-align:right; +} + #articles_data table tr.category { height:35px; background-color:#c8102e; + font-family:"Roboto Slab"; font-size:16px; - color:#FFF; font-weight:bold; + color:#FFF; } #articles_data table tr.category>td:first-child { @@ -423,3 +433,7 @@ input[type=number]::-webkit-outer-spin-button { .kpsul_middle_right_col { overflow:auto; } + +.kpsul_middle_right_col #history .day { + top: 0; +} diff --git a/kfet/static/kfet/css/libs/columns.css b/kfet/static/kfet/css/libs/columns.css new file mode 100644 index 00000000..34591061 --- /dev/null +++ b/kfet/static/kfet/css/libs/columns.css @@ -0,0 +1,43 @@ +.unbreakable { + display:inline-block; + width: 100%; +} + +.column-xs-1, .column-sm-1, .column-md-1, .column-lg-1, +.column-xs-2, .column-sm-2, .column-md-2, .column-lg-2, +.column-xs-3, .column-sm-3, .column-md-3, .column-lg-3, +.column-xs-4, .column-sm-4, .column-md-4, .column-lg-4, +.column-xs-5, .column-sm-5, .column-md-5, .column-lg-5 { + column-count: 1; +} + +.column-xs-1 { column-count: 1; } +.column-xs-2 { column-count: 2; } +.column-xs-3 { column-count: 3; } +.column-xs-4 { column-count: 4; } +.column-xs-5 { column-count: 5; } + +@media (min-width: 768px) { + .column-sm-1 { column-count: 1; } + .column-sm-2 { column-count: 2; } + .column-sm-3 { column-count: 3; } + .column-sm-4 { column-count: 4; } + .column-sm-5 { column-count: 5; } +} + +@media (min-width: 992px) { + .column-md-1 { column-count: 1; } + .column-md-2 { column-count: 2; } + .column-md-3 { column-count: 3; } + .column-md-4 { column-count: 4; } + .column-md-5 { column-count: 5; } +} + +@media (min-width: 1200px) { + .column-lg-1 { column-count: 1; } + .column-lg-2 { column-count: 2; } + .column-lg-3 { column-count: 3; } + .column-lg-4 { column-count: 4; } + .column-lg-5 { column-count: 5; } +} + diff --git a/kfet/static/kfet/css/jconfirm-kfet.css b/kfet/static/kfet/css/libs/jconfirm-kfet.css similarity index 80% rename from kfet/static/kfet/css/jconfirm-kfet.css rename to kfet/static/kfet/css/libs/jconfirm-kfet.css index 1aee70f1..d2803434 100644 --- a/kfet/static/kfet/css/jconfirm-kfet.css +++ b/kfet/static/kfet/css/libs/jconfirm-kfet.css @@ -5,7 +5,7 @@ .jconfirm .jconfirm-box { padding:0; border-radius:0 !important; - font-family:"Roboto Mono"; + font-family:Roboto; } .jconfirm .jconfirm-box div.title-c .title { @@ -28,7 +28,6 @@ .jconfirm .jconfirm-box .content { border-bottom:1px solid #ddd; - padding:5px 10px; } .jconfirm .jconfirm-box input { @@ -37,6 +36,7 @@ border:0; + font-family:"Roboto Mono"; font-size:40px; text-align:center; @@ -49,6 +49,7 @@ } .jconfirm .jconfirm-box .buttons button { + min-width:40px; height:100%; margin:0; margin:0 !important; @@ -84,24 +85,3 @@ padding-right: 50px; padding-left: 50px; } - -/* Account autocomplete window */ - -#account_results ul { - list-style-type:none; - background:rgba(255,255,255,0.9); - padding:0; -} - -#account_results li { - display:block; - padding:5px 20px; - height:100%; - width:100%; -} - -#account_results .hilight { - background:rgba(200,16,46,0.9); - color:#fff; - text-decoration:none; -} diff --git a/kfet/static/kfet/css/libs/jquery-tablesorter-kfet.css b/kfet/static/kfet/css/libs/jquery-tablesorter-kfet.css new file mode 100644 index 00000000..e69de29b diff --git a/kfet/static/kfet/css/libs/multiple-select-kfet.css b/kfet/static/kfet/css/libs/multiple-select-kfet.css new file mode 100644 index 00000000..145968d3 --- /dev/null +++ b/kfet/static/kfet/css/libs/multiple-select-kfet.css @@ -0,0 +1,14 @@ +/** + * Multiple Select plugin customizations + */ + +.ms-choice { + height: 34px !important; + line-height: 34px !important; + border: 1px solid #ccc !important; + box-shadow: inset 0 1px 1px rgba(0,0,0,.075) !important; +} + +.ms-choice > div { + top: 4px !important; +} diff --git a/kfet/static/kfet/css/nav.css b/kfet/static/kfet/css/nav.css index 5ffc7b24..bec05ccf 100644 --- a/kfet/static/kfet/css/nav.css +++ b/kfet/static/kfet/css/nav.css @@ -1,67 +1,88 @@ -nav { - background:#000; - color:#DDD; - font-family:Oswald; +.navbar { + background: #000; + color: #DDD; + font-family: Oswald; + border: 0; } -.navbar-nav > li > .dropdown-menu { - border:0; - border-radius:0; +.navbar .navbar-brand { + padding: 3px 25px; } -.navbar-fixed-top { - border:0; +.navbar .navbar-brand img { + height: 44px; } -nav .navbar-brand { - padding:3px 25px; -} - -nav .navbar-brand img { - height:44px; -} - -nav .navbar-toggle .icon-bar { - background-color:#FFF; -} - -nav a { - color:#DDD; +.navbar .navbar-toggle .icon-bar { + background-color: #FFF; } .navbar-nav { - font-weight:bold; - font-size:14px; - text-transform:uppercase; + font-weight: bold; + font-size: 14px; + text-transform: uppercase; + margin: 0 -15px; } -.nav>li>a:focus, .nav>li>a:hover { - background-color:#C8102E; - color:#FFF; -} - -.nav .open>a, .nav .open>a:focus, .nav .open>a:hover { - background-color:#C8102E; -} - -.dropdown-menu { - padding:0; -} - -.dropdown-menu>li>a { - padding:8px 20px; -} - -.dropdown-menu .divider { - margin:0; -} - -@media (max-width: 767px) { - .navbar-nav .open .dropdown-menu { - background-color:#FFF; - } - +@media (min-width: 768px) { .navbar-nav { - margin:0 -15px; + margin: 0px; + } + .navbar-right { + margin-right: -15px; + } +} + +.navbar-nav a { + transition: background-color, box-shadow, color; + transition-duration: 0.15s; +} + +.navbar-nav > li > a { + color: #FFF; +} + +.navbar-nav > li:hover > a, +.navbar-nav > li > a:focus, +.nav .open > a:hover, +.nav .open > a:focus { + background-color: #C8102E; + color: #FFF; + box-shadow: inset 0 5px 5px -5px #000; +} + +.navbar .dropdown .dropdown-menu { + padding: 0; + border: 0; + border-radius: 0; + background-color: #FFF; +} + +.navbar .dropdown .dropdown-menu > li > a { + padding: 8px 20px; + color: #000; +} + +.navbar .dropdown .dropdown-menu > li > a:hover, +.navbar .dropdown .dropdown-meny > li > a:focus { + color: #c8102e; + background-color: transparent; +} + +.navbar .dropdown .dropdown-menu .divider { + margin: 0; +} + +@media (min-width: 768px) { + .navbar .dropdown .dropdown-menu { + display: block; + visibility: hidden; + opacity: 0; + transition: opacity 0.15s; + } + + .navbar .dropdown:hover .dropdown-menu { + visibility: visible; + opacity: 1; } } diff --git a/kfet/static/kfet/img/favicon.png b/kfet/static/kfet/img/favicon.png new file mode 100644 index 00000000..56fb4299 Binary files /dev/null and b/kfet/static/kfet/img/favicon.png differ diff --git a/kfet/static/kfet/js/kfet.js b/kfet/static/kfet/js/kfet.js index cc369e32..144582ca 100644 --- a/kfet/static/kfet/js/kfet.js +++ b/kfet/static/kfet/js/kfet.js @@ -1,32 +1,33 @@ -$(document).ready(function() { - $(window).scroll(function() { - if ($(window).width() >= 768 && $(this).scrollTop() > 72.6) { - $('.col-content-left').css({'position':'fixed', 'top':'50px'}); - $('.col-content-right').addClass('col-sm-offset-4 col-md-offset-3'); - } else { - $('.col-content-left').css({'position':'relative', 'top':'0'}); - $('.col-content-right').removeClass('col-sm-offset-4 col-md-offset-3'); - } - }); +/** + * CSRF Token + */ - if (typeof Cookies !== 'undefined') { - // Retrieving csrf token - csrftoken = Cookies.get('csrftoken'); - // Appending csrf token to ajax post requests - function csrfSafeMethod(method) { - // these HTTP methods do not require CSRF protection - return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); +var csrftoken = ''; +if (typeof Cookies !== 'undefined') + csrftoken = Cookies.get('csrftoken'); + +// Add CSRF token in header of AJAX requests. + +function csrfSafeMethod(method) { + // these HTTP methods do not require CSRF protection + return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); +} + +$.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!csrfSafeMethod(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken); } - $.ajaxSetup({ - beforeSend: function(xhr, settings) { - if (!csrfSafeMethod(settings.type) && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } - } - }); } }); +function add_csrf_form($form) { + $form.append( + $('', {'name': 'csrfmiddlewaretoken', 'value': csrftoken}) + ); +} + + /* * Generic Websocket class and k-psul ws instanciation */ @@ -34,19 +35,24 @@ $(document).ready(function() { class KfetWebsocket { static get defaults() { - return {"relative_url": "", "default_msg": {}, "handlers": []}; + return { + relative_url: '', + default_msg: {}, + handlers: [], + base_path: '/ws/k-fet/' + }; } constructor(data) { $.extend(this, this.constructor.defaults, data); + if (window.location.pathname.startsWith('/gestion/')) + this.base_path = '/gestion' + this.base_path; } - - get url() { - var websocket_protocol = window.location.protocol == 'https:' ? 'wss' : 'ws'; - var location_host = window.location.host; - var location_url = window.location.pathname.startsWith('/gestion/') ? location_host + '/gestion' : location_host; - return websocket_protocol+"://" + location_url + this.relative_url ; + get url() { + var protocol = window.location.protocol == 'https:' ? 'wss' : 'ws'; + var host = window.location.host; + return protocol + "://" + host + this.base_path + this.relative_url; } add_handler(handler) { @@ -70,7 +76,7 @@ class KfetWebsocket { } var OperationWebSocket = new KfetWebsocket({ - 'relative_url': '/ws/k-fet/k-psul/', + 'relative_url': 'k-psul/', 'default_msg': {'opegroups':[],'opes':[],'checkouts':[],'articles':[]}, }); @@ -194,3 +200,112 @@ function requestAuth(data, callback, focus_next = null) { }); } + + +/** + * Setup jquery-confirm + */ + +jconfirm.defaults = { + confirmButton: '', + cancelButton: '' +}; + + +/** + * Create form node, given an url used as 'action', with csrftoken set. + */ +function create_form(url) { + let $form = $('', { + 'action': url, + 'method': 'post', + }); + add_csrf_form($form); + return $form; +} + + +/** + * Emit a POST request from tag. + * + * Usage: + * {…} + */ +function submit_url(el) { + let url = $(el).data('url'); + create_form(url).appendTo($('body')).submit(); +} + + +/** + * jquery-tablesorter + * https://mottie.github.io/tablesorter/docs/ + * + * Known bugs (v2.29.0): + * - Sort order icons in sticky headers are not updated. + * Status: Fixed in next release. + * + * TODO: + * - Handle i18n. + */ + + +function registerBoolParser(id, true_str, false_str) { + $.tablesorter.addParser({ + id: id, + format: function(s) { + return s.toLowerCase() + .replace(true_str, 1) + .replace(false_str, 0); + }, + type: 'numeric' + }); +} + + +// Parsers for the text representations of boolean. +registerBoolParser('yesno', 'oui', 'non'); + +registerBoolParser('article__is_sold', 'en vente', 'non vendu'); +registerBoolParser('article__hidden', 'caché', 'affiché'); + + +// https://mottie.github.io/tablesorter/docs/index.html#variable-defaults +$.extend(true, $.tablesorter.defaults, { + headerTemplate: '{content} {icon}', + + cssIconAsc : 'glyphicon glyphicon-chevron-up', + cssIconDesc : 'glyphicon glyphicon-chevron-down', + cssIconNone : 'glyphicon glyphicon-resize-vertical', + + // Only four-digits format year is handled by the builtin parser + // 'shortDate'. + dateFormat: 'ddmmyyyy', + + // Accented characters are replaced with their non-accented one. + sortLocaleCompare: true, + // French format: 1 234,56 + usNumberFormat: false, + + widgets: ['stickyHeaders'], + widgetOptions: { + stickyHeaders_offset: '.navbar', + } +}); + + +// https://mottie.github.io/tablesorter/docs/index.html#variable-language +$.extend($.tablesorter.language, { + sortAsc : 'Trié par ordre croissant, ', + sortDesc : 'Trié par ordre décroissant, ', + sortNone : 'Non trié, ', + sortDisabled : 'tri désactivé et/ou non-modifiable', + nextAsc : 'cliquer pour trier par ordre croissant', + nextDesc : 'cliquer pour trier par ordre décroissant', + nextNone : 'cliquer pour retirer le tri' +}); + + +$( function() { + $('.sortable').tablesorter(); +}); diff --git a/kfet/static/kfet/js/statistic.js b/kfet/static/kfet/js/statistic.js index db31e0e8..9baa08c4 100644 --- a/kfet/static/kfet/js/statistic.js +++ b/kfet/static/kfet/js/statistic.js @@ -7,7 +7,7 @@ var self = this; var element = $(target); - var content = $("
    "); + var content = $("
    "); var buttons; function dictToArray (dict, start) { @@ -67,7 +67,6 @@ { label: chart.label, borderColor: chart.color, - backgroundColor: chart.color, fill: is_time_chart, lineTension: 0, data: chart_data, @@ -154,9 +153,8 @@ // initialize the interface function initialize (data) { // creates the bar with the buttons - buttons = $("
    ", - {class: "btn-group btn-group-justified", - role: "group", + buttons = $("
    ' ).appendTo( $filter ); + } else { + ffxn = ts.getColumnData( table, wo.filter_formatter, column ); + if ( ffxn ) { + wo.filter_formatterCount++; + buildFilter = ffxn( $filter, column ); + // no element returned, so lets go find it + if ( buildFilter && buildFilter.length === 0 ) { + buildFilter = $filter.children( 'input' ); + } + // element not in DOM, so lets attach it + if ( buildFilter && ( buildFilter.parent().length === 0 || + ( buildFilter.parent().length && buildFilter.parent()[0] !== $filter[0] ) ) ) { + $filter.append( buildFilter ); + } + } else { + buildFilter = $( '' ).appendTo( $filter ); + } + if ( buildFilter ) { + tmp = $header.data( 'placeholder' ) || + $header.attr( 'data-placeholder' ) || + wo.filter_placeholder.search || ''; + buildFilter.attr( 'placeholder', tmp ); + } + } + if ( buildFilter ) { + // add filter class name + name = ( $.isArray( wo.filter_cssFilter ) ? + ( typeof wo.filter_cssFilter[column] !== 'undefined' ? wo.filter_cssFilter[column] || '' : '' ) : + wo.filter_cssFilter ) || ''; + // copy data-column from table cell (it will include colspan) + buildFilter.addClass( tscss.filter + ' ' + name ).attr( 'data-column', $filter.attr( 'data-column' ) ); + if ( disabled ) { + buildFilter.attr( 'placeholder', '' ).addClass( tscss.filterDisabled )[0].disabled = true; + } + } + } + } + }, + bindSearch: function( table, $el, internal ) { + table = $( table )[0]; + $el = $( $el ); // allow passing a selector string + if ( !$el.length ) { return; } + var tmp, + c = table.config, + wo = c.widgetOptions, + namespace = c.namespace + 'filter', + $ext = wo.filter_$externalFilters; + if ( internal !== true ) { + // save anyMatch element + tmp = wo.filter_anyColumnSelector + ',' + wo.filter_multipleColumnSelector; + wo.filter_$anyMatch = $el.filter( tmp ); + if ( $ext && $ext.length ) { + wo.filter_$externalFilters = wo.filter_$externalFilters.add( $el ); + } else { + wo.filter_$externalFilters = $el; + } + // update values ( external filters added after table initialization ) + ts.setFilters( table, c.$table.data( 'lastSearch' ) || [], internal === false ); + } + // unbind events + tmp = ( 'keypress keyup keydown search change input '.split( ' ' ).join( namespace + ' ' ) ); + $el + // use data attribute instead of jQuery data since the head is cloned without including + // the data/binding + .attr( 'data-lastSearchTime', new Date().getTime() ) + .unbind( tmp.replace( ts.regex.spaces, ' ' ) ) + .bind( 'keydown' + namespace, function( event ) { + if ( event.which === tskeyCodes.escape && !table.config.widgetOptions.filter_resetOnEsc ) { + // prevent keypress event + return false; + } + }) + .bind( 'keyup' + namespace, function( event ) { + wo = table.config.widgetOptions; // make sure "wo" isn't cached + var column = parseInt( $( this ).attr( 'data-column' ), 10 ), + liveSearch = typeof wo.filter_liveSearch === 'boolean' ? wo.filter_liveSearch : + ts.getColumnData( table, wo.filter_liveSearch, column ); + if ( typeof liveSearch === 'undefined' ) { + liveSearch = wo.filter_liveSearch.fallback || false; + } + $( this ).attr( 'data-lastSearchTime', new Date().getTime() ); + // emulate what webkit does.... escape clears the filter + if ( event.which === tskeyCodes.escape ) { + // make sure to restore the last value on escape + this.value = wo.filter_resetOnEsc ? '' : c.lastSearch[column]; + // don't return if the search value is empty ( all rows need to be revealed ) + } else if ( this.value !== '' && ( + // liveSearch can contain a min value length; ignore arrow and meta keys, but allow backspace + ( typeof liveSearch === 'number' && this.value.length < liveSearch ) || + // let return & backspace continue on, but ignore arrows & non-valid characters + ( event.which !== tskeyCodes.enter && event.which !== tskeyCodes.backSpace && + ( event.which < tskeyCodes.space || ( event.which >= tskeyCodes.left && event.which <= tskeyCodes.down ) ) ) ) ) { + return; + // live search + } else if ( liveSearch === false ) { + if ( this.value !== '' && event.which !== tskeyCodes.enter ) { + return; + } + } + // change event = no delay; last true flag tells getFilters to skip newest timed input + tsf.searching( table, true, true, column ); + }) + // include change for select - fixes #473 + .bind( 'search change keypress input blur '.split( ' ' ).join( namespace + ' ' ), function( event ) { + // don't get cached data, in case data-column changes dynamically + var column = parseInt( $( this ).attr( 'data-column' ), 10 ), + eventType = event.type, + liveSearch = typeof wo.filter_liveSearch === 'boolean' ? + wo.filter_liveSearch : + ts.getColumnData( table, wo.filter_liveSearch, column ); + if ( table.config.widgetOptions.filter_initialized && + // immediate search if user presses enter + ( event.which === tskeyCodes.enter || + // immediate search if a "search" or "blur" is triggered on the input + ( eventType === 'search' || eventType === 'blur' ) || + // change & input events must be ignored if liveSearch !== true + ( eventType === 'change' || eventType === 'input' ) && + // prevent search if liveSearch is a number + ( liveSearch === true || liveSearch !== true && event.target.nodeName !== 'INPUT' ) && + // don't allow 'change' or 'input' event to process if the input value + // is the same - fixes #685 + this.value !== c.lastSearch[column] + ) + ) { + event.preventDefault(); + // init search with no delay + $( this ).attr( 'data-lastSearchTime', new Date().getTime() ); + tsf.searching( table, eventType !== 'keypress', true, column ); + } + }); + }, + searching: function( table, filter, skipFirst, column ) { + var liveSearch, + wo = table.config.widgetOptions; + if (typeof column === 'undefined') { + // no delay + liveSearch = false; + } else { + liveSearch = typeof wo.filter_liveSearch === 'boolean' ? + wo.filter_liveSearch : + // get column setting, or set to fallback value, or default to false + ts.getColumnData( table, wo.filter_liveSearch, column ); + if ( typeof liveSearch === 'undefined' ) { + liveSearch = wo.filter_liveSearch.fallback || false; + } + } + clearTimeout( wo.filter_searchTimer ); + if ( typeof filter === 'undefined' || filter === true ) { + // delay filtering + wo.filter_searchTimer = setTimeout( function() { + tsf.checkFilters( table, filter, skipFirst ); + }, liveSearch ? wo.filter_searchDelay : 10 ); + } else { + // skip delay + tsf.checkFilters( table, filter, skipFirst ); + } + }, + equalFilters: function (c, filter1, filter2) { + var indx, + f1 = [], + f2 = [], + len = c.columns + 1; // add one to include anyMatch filter + filter1 = $.isArray(filter1) ? filter1 : []; + filter2 = $.isArray(filter2) ? filter2 : []; + for (indx = 0; indx < len; indx++) { + f1[indx] = filter1[indx] || ''; + f2[indx] = filter2[indx] || ''; + } + return f1.join(',') === f2.join(','); + }, + checkFilters: function( table, filter, skipFirst ) { + var c = table.config, + wo = c.widgetOptions, + filterArray = $.isArray( filter ), + filters = ( filterArray ) ? filter : ts.getFilters( table, true ), + currentFilters = filters || []; // current filter values + // prevent errors if delay init is set + if ( $.isEmptyObject( c.cache ) ) { + // update cache if delayInit set & pager has initialized ( after user initiates a search ) + if ( c.delayInit && ( !c.pager || c.pager && c.pager.initialized ) ) { + ts.updateCache( c, function() { + tsf.checkFilters( table, false, skipFirst ); + }); + } + return; + } + // add filter array back into inputs + if ( filterArray ) { + ts.setFilters( table, filters, false, skipFirst !== true ); + if ( !wo.filter_initialized ) { + c.lastSearch = []; + c.lastCombinedFilter = ''; + } + } + if ( wo.filter_hideFilters ) { + // show/hide filter row as needed + c.$table + .find( '.' + tscss.filterRow ) + .triggerHandler( tsf.hideFiltersCheck( c ) ? 'mouseleave' : 'mouseenter' ); + } + // return if the last search is the same; but filter === false when updating the search + // see example-widget-filter.html filter toggle buttons + if ( tsf.equalFilters(c, c.lastSearch, currentFilters) && filter !== false ) { + return; + } else if ( filter === false ) { + // force filter refresh + c.lastCombinedFilter = ''; + c.lastSearch = []; + } + // define filter inside it is false + filters = filters || []; + // convert filters to strings - see #1070 + filters = Array.prototype.map ? + filters.map( String ) : + // for IE8 & older browsers - maybe not the best method + filters.join( '\ufffd' ).split( '\ufffd' ); + + if ( wo.filter_initialized ) { + c.$table.triggerHandler( 'filterStart', [ filters ] ); + } + if ( c.showProcessing ) { + // give it time for the processing icon to kick in + setTimeout( function() { + tsf.findRows( table, filters, currentFilters ); + return false; + }, 30 ); + } else { + tsf.findRows( table, filters, currentFilters ); + return false; + } + }, + hideFiltersCheck: function( c ) { + if (typeof c.widgetOptions.filter_hideFilters === 'function') { + var val = c.widgetOptions.filter_hideFilters( c ); + if (typeof val === 'boolean') { + return val; + } + } + return ts.getFilters( c.$table ).join( '' ) === ''; + }, + hideFilters: function( c, $table ) { + var timer; + ( $table || c.$table ) + .find( '.' + tscss.filterRow ) + .addClass( tscss.filterRowHide ) + .bind( 'mouseenter mouseleave', function( e ) { + // save event object - http://bugs.jquery.com/ticket/12140 + var event = e, + $row = $( this ); + clearTimeout( timer ); + timer = setTimeout( function() { + if ( /enter|over/.test( event.type ) ) { + $row.removeClass( tscss.filterRowHide ); + } else { + // don't hide if input has focus + // $( ':focus' ) needs jQuery 1.6+ + if ( $( document.activeElement ).closest( 'tr' )[0] !== $row[0] ) { + // don't hide row if any filter has a value + $row.toggleClass( tscss.filterRowHide, tsf.hideFiltersCheck( c ) ); + } + } + }, 200 ); + }) + .find( 'input, select' ).bind( 'focus blur', function( e ) { + var event = e, + $row = $( this ).closest( 'tr' ); + clearTimeout( timer ); + timer = setTimeout( function() { + clearTimeout( timer ); + // don't hide row if any filter has a value + $row.toggleClass( tscss.filterRowHide, tsf.hideFiltersCheck( c ) && event.type !== 'focus' ); + }, 200 ); + }); + }, + defaultFilter: function( filter, mask ) { + if ( filter === '' ) { return filter; } + var regex = tsfRegex.iQuery, + maskLen = mask.match( tsfRegex.igQuery ).length, + query = maskLen > 1 ? $.trim( filter ).split( /\s/ ) : [ $.trim( filter ) ], + len = query.length - 1, + indx = 0, + val = mask; + if ( len < 1 && maskLen > 1 ) { + // only one 'word' in query but mask has >1 slots + query[1] = query[0]; + } + // replace all {query} with query words... + // if query = 'Bob', then convert mask from '!{query}' to '!Bob' + // if query = 'Bob Joe Frank', then convert mask '{q} OR {q}' to 'Bob OR Joe OR Frank' + while ( regex.test( val ) ) { + val = val.replace( regex, query[indx++] || '' ); + if ( regex.test( val ) && indx < len && ( query[indx] || '' ) !== '' ) { + val = mask.replace( regex, val ); + } + } + return val; + }, + getLatestSearch: function( $input ) { + if ( $input ) { + return $input.sort( function( a, b ) { + return $( b ).attr( 'data-lastSearchTime' ) - $( a ).attr( 'data-lastSearchTime' ); + }); + } + return $input || $(); + }, + findRange: function( c, val, ignoreRanges ) { + // look for multiple columns '1-3,4-6,8' in data-column + var temp, ranges, range, start, end, singles, i, indx, len, + columns = []; + if ( /^[0-9]+$/.test( val ) ) { + // always return an array + return [ parseInt( val, 10 ) ]; + } + // process column range + if ( !ignoreRanges && /-/.test( val ) ) { + ranges = val.match( /(\d+)\s*-\s*(\d+)/g ); + len = ranges ? ranges.length : 0; + for ( indx = 0; indx < len; indx++ ) { + range = ranges[indx].split( /\s*-\s*/ ); + start = parseInt( range[0], 10 ) || 0; + end = parseInt( range[1], 10 ) || ( c.columns - 1 ); + if ( start > end ) { + temp = start; start = end; end = temp; // swap + } + if ( end >= c.columns ) { + end = c.columns - 1; + } + for ( ; start <= end; start++ ) { + columns[ columns.length ] = start; + } + // remove processed range from val + val = val.replace( ranges[ indx ], '' ); + } + } + // process single columns + if ( !ignoreRanges && /,/.test( val ) ) { + singles = val.split( /\s*,\s*/ ); + len = singles.length; + for ( i = 0; i < len; i++ ) { + if ( singles[ i ] !== '' ) { + indx = parseInt( singles[ i ], 10 ); + if ( indx < c.columns ) { + columns[ columns.length ] = indx; + } + } + } + } + // return all columns + if ( !columns.length ) { + for ( indx = 0; indx < c.columns; indx++ ) { + columns[ columns.length ] = indx; + } + } + return columns; + }, + getColumnElm: function( c, $elements, column ) { + // data-column may contain multiple columns '1-3,5-6,8' + // replaces: c.$filters.filter( '[data-column="' + column + '"]' ); + return $elements.filter( function() { + var cols = tsf.findRange( c, $( this ).attr( 'data-column' ) ); + return $.inArray( column, cols ) > -1; + }); + }, + multipleColumns: function( c, $input ) { + // look for multiple columns '1-3,4-6,8' in data-column + var wo = c.widgetOptions, + // only target 'all' column inputs on initialization + // & don't target 'all' column inputs if they don't exist + targets = wo.filter_initialized || !$input.filter( wo.filter_anyColumnSelector ).length, + val = $.trim( tsf.getLatestSearch( $input ).attr( 'data-column' ) || '' ); + return tsf.findRange( c, val, !targets ); + }, + processTypes: function( c, data, vars ) { + var ffxn, + filterMatched = null, + matches = null; + for ( ffxn in tsf.types ) { + if ( $.inArray( ffxn, vars.excludeMatch ) < 0 && matches === null ) { + matches = tsf.types[ffxn]( c, data, vars ); + if ( matches !== null ) { + filterMatched = matches; + } + } + } + return filterMatched; + }, + matchType: function( c, columnIndex ) { + var isMatch, + wo = c.widgetOptions, + $el = c.$headerIndexed[ columnIndex ]; + // filter-exact > filter-match > filter_matchType for type + if ( $el.hasClass( 'filter-exact' ) ) { + isMatch = false; + } else if ( $el.hasClass( 'filter-match' ) ) { + isMatch = true; + } else { + // filter-select is not applied when filter_functions are used, so look for a select + if ( wo.filter_columnFilters ) { + $el = c.$filters + .find( '.' + tscss.filter ) + .add( wo.filter_$externalFilters ) + .filter( '[data-column="' + columnIndex + '"]' ); + } else if ( wo.filter_$externalFilters ) { + $el = wo.filter_$externalFilters.filter( '[data-column="' + columnIndex + '"]' ); + } + isMatch = $el.length ? + c.widgetOptions.filter_matchType[ ( $el[ 0 ].nodeName || '' ).toLowerCase() ] === 'match' : + // default to exact, if no inputs found + false; + } + return isMatch; + }, + processRow: function( c, data, vars ) { + var result, filterMatched, + fxn, ffxn, txt, + wo = c.widgetOptions, + showRow = true, + hasAnyMatchInput = wo.filter_$anyMatch && wo.filter_$anyMatch.length, + + // if wo.filter_$anyMatch data-column attribute is changed dynamically + // we don't want to do an "anyMatch" search on one column using data + // for the entire row - see #998 + columnIndex = wo.filter_$anyMatch && wo.filter_$anyMatch.length ? + // look for multiple columns '1-3,4-6,8' + tsf.multipleColumns( c, wo.filter_$anyMatch ) : + []; + data.$cells = data.$row.children(); + if ( data.anyMatchFlag && columnIndex.length > 1 || ( data.anyMatchFilter && !hasAnyMatchInput ) ) { + data.anyMatch = true; + data.isMatch = true; + data.rowArray = data.$cells.map( function( i ) { + if ( $.inArray( i, columnIndex ) > -1 || ( data.anyMatchFilter && !hasAnyMatchInput ) ) { + if ( data.parsed[ i ] ) { + txt = data.cacheArray[ i ]; + } else { + txt = data.rawArray[ i ]; + txt = $.trim( wo.filter_ignoreCase ? txt.toLowerCase() : txt ); + if ( c.sortLocaleCompare ) { + txt = ts.replaceAccents( txt ); + } + } + return txt; + } + }).get(); + data.filter = data.anyMatchFilter; + data.iFilter = data.iAnyMatchFilter; + data.exact = data.rowArray.join( ' ' ); + data.iExact = wo.filter_ignoreCase ? data.exact.toLowerCase() : data.exact; + data.cache = data.cacheArray.slice( 0, -1 ).join( ' ' ); + vars.excludeMatch = vars.noAnyMatch; + filterMatched = tsf.processTypes( c, data, vars ); + if ( filterMatched !== null ) { + showRow = filterMatched; + } else { + if ( wo.filter_startsWith ) { + showRow = false; + // data.rowArray may not contain all columns + columnIndex = Math.min( c.columns, data.rowArray.length ); + while ( !showRow && columnIndex > 0 ) { + columnIndex--; + showRow = showRow || data.rowArray[ columnIndex ].indexOf( data.iFilter ) === 0; + } + } else { + showRow = ( data.iExact + data.childRowText ).indexOf( data.iFilter ) >= 0; + } + } + data.anyMatch = false; + // no other filters to process + if ( data.filters.join( '' ) === data.filter ) { + return showRow; + } + } + + for ( columnIndex = 0; columnIndex < c.columns; columnIndex++ ) { + data.filter = data.filters[ columnIndex ]; + data.index = columnIndex; + + // filter types to exclude, per column + vars.excludeMatch = vars.excludeFilter[ columnIndex ]; + + // ignore if filter is empty or disabled + if ( data.filter ) { + data.cache = data.cacheArray[ columnIndex ]; + result = data.parsed[ columnIndex ] ? data.cache : data.rawArray[ columnIndex ] || ''; + data.exact = c.sortLocaleCompare ? ts.replaceAccents( result ) : result; // issue #405 + data.iExact = !tsfRegex.type.test( typeof data.exact ) && wo.filter_ignoreCase ? + data.exact.toLowerCase() : data.exact; + data.isMatch = tsf.matchType( c, columnIndex ); + + result = showRow; // if showRow is true, show that row + + // in case select filter option has a different value vs text 'a - z|A through Z' + ffxn = wo.filter_columnFilters ? + c.$filters.add( wo.filter_$externalFilters ) + .filter( '[data-column="' + columnIndex + '"]' ) + .find( 'select option:selected' ) + .attr( 'data-function-name' ) || '' : ''; + // replace accents - see #357 + if ( c.sortLocaleCompare ) { + data.filter = ts.replaceAccents( data.filter ); + } + + // replace column specific default filters - see #1088 + if ( wo.filter_defaultFilter && tsfRegex.iQuery.test( vars.defaultColFilter[ columnIndex ] ) ) { + data.filter = tsf.defaultFilter( data.filter, vars.defaultColFilter[ columnIndex ] ); + } + + // data.iFilter = case insensitive ( if wo.filter_ignoreCase is true ), + // data.filter = case sensitive + data.iFilter = wo.filter_ignoreCase ? ( data.filter || '' ).toLowerCase() : data.filter; + fxn = vars.functions[ columnIndex ]; + filterMatched = null; + if ( fxn ) { + if ( typeof fxn === 'function' ) { + // filter callback( exact cell content, parser normalized content, + // filter input value, column index, jQuery row object ) + filterMatched = fxn( data.exact, data.cache, data.filter, columnIndex, data.$row, c, data ); + } else if ( typeof fxn[ ffxn || data.filter ] === 'function' ) { + // selector option function + txt = ffxn || data.filter; + filterMatched = + fxn[ txt ]( data.exact, data.cache, data.filter, columnIndex, data.$row, c, data ); + } + } + if ( filterMatched === null ) { + // cycle through the different filters + // filters return a boolean or null if nothing matches + filterMatched = tsf.processTypes( c, data, vars ); + if ( filterMatched !== null ) { + result = filterMatched; + // Look for match, and add child row data for matching + } else { + // check fxn (filter-select in header) after filter types are checked + // without this, the filter + jQuery UI selectmenu demo was breaking + if ( fxn === true ) { + // default selector uses exact match unless 'filter-match' class is found + result = data.isMatch ? + // data.iExact may be a number + ( '' + data.iExact ).search( data.iFilter ) >= 0 : + data.filter === data.exact; + } else { + txt = ( data.iExact + data.childRowText ).indexOf( tsf.parseFilter( c, data.iFilter, data ) ); + result = ( ( !wo.filter_startsWith && txt >= 0 ) || ( wo.filter_startsWith && txt === 0 ) ); + } + } + } else { + result = filterMatched; + } + showRow = ( result ) ? showRow : false; + } + } + return showRow; + }, + findRows: function( table, filters, currentFilters ) { + if ( + tsf.equalFilters(table.config, table.config.lastSearch, currentFilters) || + !table.config.widgetOptions.filter_initialized + ) { + return; + } + var len, norm_rows, rowData, $rows, $row, rowIndex, tbodyIndex, $tbody, columnIndex, + isChild, childRow, lastSearch, showRow, showParent, time, val, indx, + notFiltered, searchFiltered, query, injected, res, id, txt, + storedFilters = $.extend( [], filters ), + c = table.config, + wo = c.widgetOptions, + // data object passed to filters; anyMatch is a flag for the filters + data = { + anyMatch: false, + filters: filters, + // regex filter type cache + filter_regexCache : [] + }, + vars = { + // anyMatch really screws up with these types of filters + noAnyMatch: [ 'range', 'operators' ], + // cache filter variables that use ts.getColumnData in the main loop + functions : [], + excludeFilter : [], + defaultColFilter : [], + defaultAnyFilter : ts.getColumnData( table, wo.filter_defaultFilter, c.columns, true ) || '' + }; + + // parse columns after formatter, in case the class is added at that point + data.parsed = []; + for ( columnIndex = 0; columnIndex < c.columns; columnIndex++ ) { + data.parsed[ columnIndex ] = wo.filter_useParsedData || + // parser has a "parsed" parameter + ( c.parsers && c.parsers[ columnIndex ] && c.parsers[ columnIndex ].parsed || + // getData may not return 'parsed' if other 'filter-' class names exist + // ( e.g. ) + ts.getData && ts.getData( c.$headerIndexed[ columnIndex ], + ts.getColumnData( table, c.headers, columnIndex ), 'filter' ) === 'parsed' || + c.$headerIndexed[ columnIndex ].hasClass( 'filter-parsed' ) ); + + vars.functions[ columnIndex ] = + ts.getColumnData( table, wo.filter_functions, columnIndex ) || + c.$headerIndexed[ columnIndex ].hasClass( 'filter-select' ); + vars.defaultColFilter[ columnIndex ] = + ts.getColumnData( table, wo.filter_defaultFilter, columnIndex ) || ''; + vars.excludeFilter[ columnIndex ] = + ( ts.getColumnData( table, wo.filter_excludeFilter, columnIndex, true ) || '' ).split( /\s+/ ); + } + + if ( c.debug ) { + console.log( 'Filter: Starting filter widget search', filters ); + time = new Date(); + } + // filtered rows count + c.filteredRows = 0; + c.totalRows = 0; + currentFilters = ( storedFilters || [] ); + + for ( tbodyIndex = 0; tbodyIndex < c.$tbodies.length; tbodyIndex++ ) { + $tbody = ts.processTbody( table, c.$tbodies.eq( tbodyIndex ), true ); + // skip child rows & widget added ( removable ) rows - fixes #448 thanks to @hempel! + // $rows = $tbody.children( 'tr' ).not( c.selectorRemove ); + columnIndex = c.columns; + // convert stored rows into a jQuery object + norm_rows = c.cache[ tbodyIndex ].normalized; + $rows = $( $.map( norm_rows, function( el ) { + return el[ columnIndex ].$row.get(); + }) ); + + if ( currentFilters.join('') === '' || wo.filter_serversideFiltering ) { + $rows + .removeClass( wo.filter_filteredRow ) + .not( '.' + c.cssChildRow ) + .css( 'display', '' ); + } else { + // filter out child rows + $rows = $rows.not( '.' + c.cssChildRow ); + len = $rows.length; + + if ( ( wo.filter_$anyMatch && wo.filter_$anyMatch.length ) || + typeof filters[c.columns] !== 'undefined' ) { + data.anyMatchFlag = true; + data.anyMatchFilter = '' + ( + filters[ c.columns ] || + wo.filter_$anyMatch && tsf.getLatestSearch( wo.filter_$anyMatch ).val() || + '' + ); + if ( wo.filter_columnAnyMatch ) { + // specific columns search + query = data.anyMatchFilter.split( tsfRegex.andSplit ); + injected = false; + for ( indx = 0; indx < query.length; indx++ ) { + res = query[ indx ].split( ':' ); + if ( res.length > 1 ) { + // make the column a one-based index ( non-developers start counting from one :P ) + if ( isNaN( res[0] ) ) { + $.each( c.headerContent, function( i, txt ) { + // multiple matches are possible + if ( txt.toLowerCase().indexOf( res[0] ) > -1 ) { + id = i; + filters[ id ] = res[1]; + } + }); + } else { + id = parseInt( res[0], 10 ) - 1; + } + if ( id >= 0 && id < c.columns ) { // if id is an integer + filters[ id ] = res[1]; + query.splice( indx, 1 ); + indx--; + injected = true; + } + } + } + if ( injected ) { + data.anyMatchFilter = query.join( ' && ' ); + } + } + } + + // optimize searching only through already filtered rows - see #313 + searchFiltered = wo.filter_searchFiltered; + lastSearch = c.lastSearch || c.$table.data( 'lastSearch' ) || []; + if ( searchFiltered ) { + // cycle through all filters; include last ( columnIndex + 1 = match any column ). Fixes #669 + for ( indx = 0; indx < columnIndex + 1; indx++ ) { + val = filters[indx] || ''; + // break out of loop if we've already determined not to search filtered rows + if ( !searchFiltered ) { indx = columnIndex; } + // search already filtered rows if... + searchFiltered = searchFiltered && lastSearch.length && + // there are no changes from beginning of filter + val.indexOf( lastSearch[indx] || '' ) === 0 && + // if there is NOT a logical 'or', or range ( 'to' or '-' ) in the string + !tsfRegex.alreadyFiltered.test( val ) && + // if we are not doing exact matches, using '|' ( logical or ) or not '!' + !tsfRegex.exactTest.test( val ) && + // don't search only filtered if the value is negative + // ( '> -10' => '> -100' will ignore hidden rows ) + !( tsfRegex.isNeg1.test( val ) || tsfRegex.isNeg2.test( val ) ) && + // if filtering using a select without a 'filter-match' class ( exact match ) - fixes #593 + !( val !== '' && c.$filters && c.$filters.filter( '[data-column="' + indx + '"]' ).find( 'select' ).length && + !tsf.matchType( c, indx ) ); + } + } + notFiltered = $rows.not( '.' + wo.filter_filteredRow ).length; + // can't search when all rows are hidden - this happens when looking for exact matches + if ( searchFiltered && notFiltered === 0 ) { searchFiltered = false; } + if ( c.debug ) { + console.log( 'Filter: Searching through ' + + ( searchFiltered && notFiltered < len ? notFiltered : 'all' ) + ' rows' ); + } + if ( data.anyMatchFlag ) { + if ( c.sortLocaleCompare ) { + // replace accents + data.anyMatchFilter = ts.replaceAccents( data.anyMatchFilter ); + } + if ( wo.filter_defaultFilter && tsfRegex.iQuery.test( vars.defaultAnyFilter ) ) { + data.anyMatchFilter = tsf.defaultFilter( data.anyMatchFilter, vars.defaultAnyFilter ); + // clear search filtered flag because default filters are not saved to the last search + searchFiltered = false; + } + // make iAnyMatchFilter lowercase unless both filter widget & core ignoreCase options are true + // when c.ignoreCase is true, the cache contains all lower case data + data.iAnyMatchFilter = !( wo.filter_ignoreCase && c.ignoreCase ) ? + data.anyMatchFilter : + data.anyMatchFilter.toLowerCase(); + } + + // loop through the rows + for ( rowIndex = 0; rowIndex < len; rowIndex++ ) { + + txt = $rows[ rowIndex ].className; + // the first row can never be a child row + isChild = rowIndex && tsfRegex.child.test( txt ); + // skip child rows & already filtered rows + if ( isChild || ( searchFiltered && tsfRegex.filtered.test( txt ) ) ) { + continue; + } + + data.$row = $rows.eq( rowIndex ); + data.rowIndex = rowIndex; + data.cacheArray = norm_rows[ rowIndex ]; + rowData = data.cacheArray[ c.columns ]; + data.rawArray = rowData.raw; + data.childRowText = ''; + + if ( !wo.filter_childByColumn ) { + txt = ''; + // child row cached text + childRow = rowData.child; + // so, if 'table.config.widgetOptions.filter_childRows' is true and there is + // a match anywhere in the child row, then it will make the row visible + // checked here so the option can be changed dynamically + for ( indx = 0; indx < childRow.length; indx++ ) { + txt += ' ' + childRow[indx].join( ' ' ) || ''; + } + data.childRowText = wo.filter_childRows ? + ( wo.filter_ignoreCase ? txt.toLowerCase() : txt ) : + ''; + } + + showRow = false; + showParent = tsf.processRow( c, data, vars ); + $row = rowData.$row; + + // don't pass reference to val + val = showParent ? true : false; + childRow = rowData.$row.filter( ':gt(0)' ); + if ( wo.filter_childRows && childRow.length ) { + if ( wo.filter_childByColumn ) { + if ( !wo.filter_childWithSibs ) { + // hide all child rows + childRow.addClass( wo.filter_filteredRow ); + // if only showing resulting child row, only include parent + $row = $row.eq( 0 ); + } + // cycle through each child row + for ( indx = 0; indx < childRow.length; indx++ ) { + data.$row = childRow.eq( indx ); + data.cacheArray = rowData.child[ indx ]; + data.rawArray = data.cacheArray; + val = tsf.processRow( c, data, vars ); + // use OR comparison on child rows + showRow = showRow || val; + if ( !wo.filter_childWithSibs && val ) { + childRow.eq( indx ).removeClass( wo.filter_filteredRow ); + } + } + } + // keep parent row match even if no child matches... see #1020 + showRow = showRow || showParent; + } else { + showRow = val; + } + $row + .toggleClass( wo.filter_filteredRow, !showRow )[0] + .display = showRow ? '' : 'none'; + } + } + c.filteredRows += $rows.not( '.' + wo.filter_filteredRow ).length; + c.totalRows += $rows.length; + ts.processTbody( table, $tbody, false ); + } + // lastCombinedFilter is no longer used internally + c.lastCombinedFilter = storedFilters.join(''); // save last search + // don't save 'filters' directly since it may have altered ( AnyMatch column searches ) + c.lastSearch = storedFilters; + c.$table.data( 'lastSearch', storedFilters ); + if ( wo.filter_saveFilters && ts.storage ) { + ts.storage( table, 'tablesorter-filters', tsf.processFilters( storedFilters, true ) ); + } + if ( c.debug ) { + console.log( 'Completed filter widget search' + ts.benchmark(time) ); + } + if ( wo.filter_initialized ) { + c.$table.triggerHandler( 'filterBeforeEnd', c ); + c.$table.triggerHandler( 'filterEnd', c ); + } + setTimeout( function() { + ts.applyWidget( c.table ); // make sure zebra widget is applied + }, 0 ); + }, + getOptionSource: function( table, column, onlyAvail ) { + table = $( table )[0]; + var c = table.config, + wo = c.widgetOptions, + arry = false, + source = wo.filter_selectSource, + last = c.$table.data( 'lastSearch' ) || [], + fxn = typeof source === 'function' ? true : ts.getColumnData( table, source, column ); + + if ( onlyAvail && last[column] !== '' ) { + onlyAvail = false; + } + + // filter select source option + if ( fxn === true ) { + // OVERALL source + arry = source( table, column, onlyAvail ); + } else if ( fxn instanceof $ || ( $.type( fxn ) === 'string' && fxn.indexOf( '' ) >= 0 ) ) { + // selectSource is a jQuery object or string of options + return fxn; + } else if ( $.isArray( fxn ) ) { + arry = fxn; + } else if ( $.type( source ) === 'object' && fxn ) { + // custom select source function for a SPECIFIC COLUMN + arry = fxn( table, column, onlyAvail ); + // abort - updating the selects from an external method + if (arry === null) { + return null; + } + } + if ( arry === false ) { + // fall back to original method + arry = tsf.getOptions( table, column, onlyAvail ); + } + + return tsf.processOptions( table, column, arry ); + + }, + processOptions: function( table, column, arry ) { + if ( !$.isArray( arry ) ) { + return false; + } + table = $( table )[0]; + var cts, txt, indx, len, parsedTxt, str, + c = table.config, + validColumn = typeof column !== 'undefined' && column !== null && column >= 0 && column < c.columns, + direction = validColumn ? c.$headerIndexed[ column ].hasClass( 'filter-select-sort-desc' ) : false, + parsed = []; + // get unique elements and sort the list + // if $.tablesorter.sortText exists ( not in the original tablesorter ), + // then natural sort the list otherwise use a basic sort + arry = $.grep( arry, function( value, indx ) { + if ( value.text ) { + return true; + } + return $.inArray( value, arry ) === indx; + }); + if ( validColumn && c.$headerIndexed[ column ].hasClass( 'filter-select-nosort' ) ) { + // unsorted select options + return arry; + } else { + len = arry.length; + // parse select option values + for ( indx = 0; indx < len; indx++ ) { + txt = arry[ indx ]; + // check for object + str = txt.text ? txt.text : txt; + // sortNatural breaks if you don't pass it strings + parsedTxt = ( validColumn && c.parsers && c.parsers.length && + c.parsers[ column ].format( str, table, [], column ) || str ).toString(); + parsedTxt = c.widgetOptions.filter_ignoreCase ? parsedTxt.toLowerCase() : parsedTxt; + // parse array data using set column parser; this DOES NOT pass the original + // table cell to the parser format function + if ( txt.text ) { + txt.parsed = parsedTxt; + parsed[ parsed.length ] = txt; + } else { + parsed[ parsed.length ] = { + text : txt, + // check parser length - fixes #934 + parsed : parsedTxt + }; + } + } + // sort parsed select options + cts = c.textSorter || ''; + parsed.sort( function( a, b ) { + var x = direction ? b.parsed : a.parsed, + y = direction ? a.parsed : b.parsed; + if ( validColumn && typeof cts === 'function' ) { + // custom OVERALL text sorter + return cts( x, y, true, column, table ); + } else if ( validColumn && typeof cts === 'object' && cts.hasOwnProperty( column ) ) { + // custom text sorter for a SPECIFIC COLUMN + return cts[column]( x, y, true, column, table ); + } else if ( ts.sortNatural ) { + // fall back to natural sort + return ts.sortNatural( x, y ); + } + // using an older version! do a basic sort + return true; + }); + // rebuild arry from sorted parsed data + arry = []; + len = parsed.length; + for ( indx = 0; indx < len; indx++ ) { + arry[ arry.length ] = parsed[indx]; + } + return arry; + } + }, + getOptions: function( table, column, onlyAvail ) { + table = $( table )[0]; + var rowIndex, tbodyIndex, len, row, cache, indx, child, childLen, + c = table.config, + wo = c.widgetOptions, + arry = []; + for ( tbodyIndex = 0; tbodyIndex < c.$tbodies.length; tbodyIndex++ ) { + cache = c.cache[tbodyIndex]; + len = c.cache[tbodyIndex].normalized.length; + // loop through the rows + for ( rowIndex = 0; rowIndex < len; rowIndex++ ) { + // get cached row from cache.row ( old ) or row data object + // ( new; last item in normalized array ) + row = cache.row ? + cache.row[ rowIndex ] : + cache.normalized[ rowIndex ][ c.columns ].$row[0]; + // check if has class filtered + if ( onlyAvail && row.className.match( wo.filter_filteredRow ) ) { + continue; + } + // get non-normalized cell content + if ( wo.filter_useParsedData || + c.parsers[column].parsed || + c.$headerIndexed[column].hasClass( 'filter-parsed' ) ) { + arry[ arry.length ] = '' + cache.normalized[ rowIndex ][ column ]; + // child row parsed data + if ( wo.filter_childRows && wo.filter_childByColumn ) { + childLen = cache.normalized[ rowIndex ][ c.columns ].$row.length - 1; + for ( indx = 0; indx < childLen; indx++ ) { + arry[ arry.length ] = '' + cache.normalized[ rowIndex ][ c.columns ].child[ indx ][ column ]; + } + } + } else { + // get raw cached data instead of content directly from the cells + arry[ arry.length ] = cache.normalized[ rowIndex ][ c.columns ].raw[ column ]; + // child row unparsed data + if ( wo.filter_childRows && wo.filter_childByColumn ) { + childLen = cache.normalized[ rowIndex ][ c.columns ].$row.length; + for ( indx = 1; indx < childLen; indx++ ) { + child = cache.normalized[ rowIndex ][ c.columns ].$row.eq( indx ).children().eq( column ); + arry[ arry.length ] = '' + ts.getElementText( c, child, column ); + } + } + } + } + } + return arry; + }, + buildSelect: function( table, column, arry, updating, onlyAvail ) { + table = $( table )[0]; + column = parseInt( column, 10 ); + if ( !table.config.cache || $.isEmptyObject( table.config.cache ) ) { + return; + } + + var indx, val, txt, t, $filters, $filter, option, + c = table.config, + wo = c.widgetOptions, + node = c.$headerIndexed[ column ], + // t.data( 'placeholder' ) won't work in jQuery older than 1.4.3 + options = '', + // Get curent filter value + currentValue = c.$table + .find( 'thead' ) + .find( 'select.' + tscss.filter + '[data-column="' + column + '"]' ) + .val(); + + // nothing included in arry ( external source ), so get the options from + // filter_selectSource or column data + if ( typeof arry === 'undefined' || arry === '' ) { + arry = tsf.getOptionSource( table, column, onlyAvail ); + // abort, selects are updated by an external method + if (arry === null) { + return; + } + } + + if ( $.isArray( arry ) ) { + // build option list + for ( indx = 0; indx < arry.length; indx++ ) { + option = arry[ indx ]; + if ( option.text ) { + // OBJECT!! add data-function-name in case the value is set in filter_functions + option['data-function-name'] = typeof option.value === 'undefined' ? option.text : option.value; + + // support jQuery < v1.8, otherwise the below code could be shortened to + // options += $( '
    + {% for transfer in transfergroup.transfers.all %} +
    + {{ transfer.amount }} € + {{ transfer.from_acc.trigramme }} + + {{ transfer.to_acc.trigramme }}
    -
-
+ {% endfor %} + {% endfor %} {% endblock %} {% block title %}Nouveaux transferts{% endblock %} -{% block content-header-title %}Nouveaux transferts{% endblock %} +{% block header-title %}Création de transferts{% endblock %} -{% block content %} +{% block main-size %}col-sm-12{% endblock %} -{% csrf_token %} +{% block main %}
+ {% csrf_token %}
diff --git a/kfet/templatetags/kfet_tags.py b/kfet/templatetags/kfet_tags.py index 7fa9d7c7..68b74738 100644 --- a/kfet/templatetags/kfet_tags.py +++ b/kfet/templatetags/kfet_tags.py @@ -1,15 +1,16 @@ -# -*- coding: utf-8 -*- +import re from django import template from django.utils.html import escape from django.utils.safestring import mark_safe -from math import floor -import re -from kfet.config import kfet_config +from ..utils import to_ukf + register = template.Library() +register.filter('ukf', to_ukf) + @register.filter() def highlight_text(text, q): @@ -28,7 +29,6 @@ def highlight_user(user, q): return highlight_text(text, q) - @register.filter(is_safe=True) def highlight_clipper(clipper, q): if clipper.fullname: @@ -38,8 +38,14 @@ def highlight_clipper(clipper, q): return highlight_text(text, q) +@register.filter() +def widget_type(field): + return field.field.widget.__class__.__name__ + @register.filter() -def ukf(balance, is_cof): - grant = is_cof and (1 + kfet_config.subvention_cof / 100) or 1 - return floor(balance * 10 * grant) +def slice(l, start, end=None): + if end is None: + end = start + start = 0 + return l[start:end] 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_forms.py b/kfet/tests/test_forms.py index 7f129a3f..e946d39d 100644 --- a/kfet/tests/test_forms.py +++ b/kfet/tests/test_forms.py @@ -1,56 +1,48 @@ -# -*- coding: utf-8 -*- +import datetime +from unittest import mock from django.test import TestCase -from django.contrib.auth.models import User, Group +from django.utils import timezone -from kfet.forms import UserGroupForm +from kfet.forms import KPsulCheckoutForm +from kfet.models import Checkout + +from .utils import create_user -class UserGroupFormTests(TestCase): - """Test suite for UserGroupForm.""" +class KPsulCheckoutFormTests(TestCase): def setUp(self): - # create user - self.user = User.objects.create(username="foo", password="foo") + self.now = timezone.now() - # create some K-Fêt groups - prefix_name = "K-Fêt " - names = ["Group 1", "Group 2", "Group 3"] - self.kfet_groups = [ - Group.objects.create(name=prefix_name+name) - for name in names - ] + user = create_user() - # create a non-K-Fêt group - self.other_group = Group.objects.create(name="Other group") - - def test_choices(self): - """Only K-Fêt groups are selectable.""" - form = UserGroupForm(instance=self.user) - groups_field = form.fields['groups'] - self.assertQuerysetEqual( - groups_field.queryset, - [repr(g) for g in self.kfet_groups], - ordered=False, + 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), ) - def test_keep_others(self): - """User stays in its non-K-Fêt groups.""" - user = self.user + self.form = KPsulCheckoutForm() - # add user to a non-K-Fêt group - user.groups.add(self.other_group) + def test_checkout(self): + checkout_f = self.form.fields['checkout'] + self.assertListEqual(list(checkout_f.choices), [ + ('', '---------'), + (self.c1.pk, 'C1'), + ]) - # add user to some K-Fêt groups through UserGroupForm - data = { - 'groups': [group.pk for group in self.kfet_groups], - } - form = UserGroupForm(data, instance=user) + @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.is_valid() - form.save() - self.assertQuerysetEqual( - user.groups.all(), - [repr(g) for g in [self.other_group] + self.kfet_groups], - ordered=False, - ) + form = KPsulCheckoutForm() + + checkout_f = form.fields['checkout'] + self.assertListEqual(list(checkout_f.choices), [('', '---------')]) diff --git a/kfet/tests/test_models.py b/kfet/tests/test_models.py new file mode 100644 index 00000000..727cac4e --- /dev/null +++ b/kfet/tests/test_models.py @@ -0,0 +1,60 @@ +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, Checkout + +from .utils import create_user + +User = get_user_model() + + +class AccountTests(TestCase): + + def setUp(self): + self.account = Account(trigramme='000') + self.account.save({'username': 'user'}) + + def test_password(self): + self.account.change_pwd('anna') + self.account.save() + + self.assertEqual(Account.objects.get_by_password('anna'), self.account) + + with self.assertRaises(Account.DoesNotExist): + Account.objects.get_by_password(None) + + 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_statistic.py b/kfet/tests/test_statistic.py index 4fb0785d..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 @@ -9,7 +7,8 @@ from kfet.models import Account, Article, ArticleCategory class TestStats(TestCase): - @patch('kfet.signals.messages') + + @patch('gestioncof.signals.messages') def test_user_stats(self, mock_messages): """ Checks that we can get the stat-related pages without any problem. diff --git a/kfet/tests/test_tests_utils.py b/kfet/tests/test_tests_utils.py new file mode 100644 index 00000000..8308bd5b --- /dev/null +++ b/kfet/tests/test_tests_utils.py @@ -0,0 +1,95 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from gestioncof.models import CofProfile + +from ..models import Account +from .testcases import TestCaseMixin +from .utils import ( + create_user, create_team, create_root, get_perms, user_add_perms, +) + + +User = get_user_model() + + +class UserHelpersTests(TestCaseMixin, TestCase): + + def test_create_user(self): + """create_user creates a basic user and its account.""" + u = create_user() + a = u.profile.account_kfet + + self.assertInstanceExpected(u, { + 'get_full_name': 'first last', + 'username': 'user', + }) + self.assertFalse(u.user_permissions.exists()) + + self.assertEqual('000', a.trigramme) + + def test_create_team(self): + u = create_team() + a = u.profile.account_kfet + + self.assertInstanceExpected(u, { + 'get_full_name': 'team member', + 'username': 'team', + }) + self.assertTrue(u.has_perm('kfet.is_team')) + + self.assertEqual('100', a.trigramme) + + def test_create_root(self): + u = create_root() + a = u.profile.account_kfet + + self.assertInstanceExpected(u, { + 'get_full_name': 'super user', + 'username': 'root', + 'is_superuser': True, + 'is_staff': True, + }) + + self.assertEqual('200', a.trigramme) + + +class PermHelpersTest(TestCaseMixin, TestCase): + + def setUp(self): + cts = ContentType.objects.get_for_models(Account, CofProfile) + self.perm1 = Permission.objects.create( + content_type=cts[Account], + codename='test_perm', + name='Perm for test', + ) + self.perm2 = Permission.objects.create( + content_type=cts[CofProfile], + codename='another_test_perm', + name='Another one', + ) + self.perm_team = Permission.objects.get( + content_type__app_label='kfet', + codename='is_team', + ) + + def test_get_perms(self): + perms = get_perms('kfet.test_perm', 'gestioncof.another_test_perm') + self.assertDictEqual(perms, { + 'kfet.test_perm': self.perm1, + 'gestioncof.another_test_perm': self.perm2, + }) + + def test_user_add_perms(self): + user = User.objects.create_user(username='user', password='user') + user.user_permissions.add(self.perm1) + + user_add_perms(user, ['kfet.is_team', 'gestioncof.another_test_perm']) + + self.assertQuerysetEqual( + user.user_permissions.all(), + map(repr, [self.perm1, self.perm2, self.perm_team]), + ordered=False, + ) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py new file mode 100644 index 00000000..40f895a1 --- /dev/null +++ b/kfet/tests/test_views.py @@ -0,0 +1,2240 @@ +import json +from datetime import datetime, timedelta +from decimal import Decimal +from unittest import mock + +from django.contrib.auth.models import Group +from django.core.urlresolvers import reverse +from django.test import Client, TestCase +from django.utils import timezone + +from ..config import kfet_config +from ..models import ( + Account, Article, ArticleCategory, Checkout, CheckoutStatement, Inventory, + InventoryArticle, Operation, OperationGroup, Order, OrderArticle, Supplier, + SupplierArticle, Transfer, TransferGroup, +) +from .testcases import ViewTestCaseMixin +from .utils import create_team, create_user, get_perms + + +class AccountListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account' + url_expected = '/k-fet/accounts/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class AccountValidFreeTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.is_validandfree.ajax' + url_expected = '/k-fet/accounts/is_validandfree' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok_isvalid_isfree(self): + """Upper case trigramme not taken is valid and free.""" + r = self.client.get(self.url, {'trigramme': 'AAA'}) + self.assertDictEqual(json.loads(r.content.decode('utf-8')), { + 'is_valid': True, + 'is_free': True, + }) + + def test_ok_isvalid_notfree(self): + """Already taken trigramme is not free, but valid.""" + r = self.client.get(self.url, {'trigramme': '000'}) + self.assertDictEqual(json.loads(r.content.decode('utf-8')), { + 'is_valid': True, + 'is_free': False, + }) + + def test_ok_notvalid_isfree(self): + """Lower case if forbidden but free.""" + r = self.client.get(self.url, {'trigramme': 'aaa'}) + self.assertDictEqual(json.loads(r.content.decode('utf-8')), { + 'is_valid': False, + 'is_free': True, + }) + + +class AccountCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.create' + url_expected = '/k-fet/accounts/new' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + 'trigramme': 'AAA', + 'username': 'plopplopplop', + 'first_name': 'first', + 'last_name': 'last', + 'email': 'email@domain.net', + } + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.add_account']), + } + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.account.create')) + + account = Account.objects.get(trigramme='AAA') + + self.assertInstanceExpected(account, { + 'username': 'plopplopplop', + 'first_name': 'first', + 'last_name': 'last', + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class AccountCreateAjaxViewTests(ViewTestCaseMixin, TestCase): + urls_conf = [{ + 'name': 'kfet.account.create.fromuser', + 'kwargs': {'username': 'user'}, + 'expected': '/k-fet/accounts/new/user/user', + }, { + 'name': 'kfet.account.create.fromclipper', + 'kwargs': { + 'login_clipper': 'myclipper', + 'fullname': 'first last1 last2', + }, + 'expected': ( + '/k-fet/accounts/new/clipper/myclipper/first%20last1%20last2' + ), + }, { + 'name': 'kfet.account.create.empty', + 'expected': '/k-fet/accounts/new/empty', + }] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_fromuser(self): + r = self.client.get(self.t_urls[0]) + self.assertEqual(r.status_code, 200) + + user = self.users['user'] + + self.assertEqual(r.context['user_form'].instance, user) + self.assertEqual(r.context['cof_form'].instance, user.profile) + self.assertIn('account_form', r.context) + + def test_fromclipper(self): + r = self.client.get(self.t_urls[1]) + self.assertEqual(r.status_code, 200) + + self.assertIn('user_form', r.context) + self.assertIn('cof_form', r.context) + self.assertIn('account_form', r.context) + + def test_empty(self): + r = self.client.get(self.t_urls[2]) + self.assertEqual(r.status_code, 200) + + self.assertIn('user_form', r.context) + self.assertIn('cof_form', r.context) + self.assertIn('account_form', r.context) + + +class AccountCreateAutocompleteViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.create.autocomplete' + url_expected = '/k-fet/autocomplete/account_new' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url, {'q': 'first'}) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(r.context['users_notcof']), 0) + self.assertEqual(len(r.context['users_cof']), 0) + self.assertSetEqual(set(r.context['kfet']), set([ + (self.accounts['user'], self.users['user']), + ])) + + +class AccountSearchViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.search.autocomplete' + url_expected = '/k-fet/autocomplete/account_search' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url, {'q': 'first'}) + self.assertEqual(r.status_code, 200) + self.assertSetEqual(set(r.context['accounts']), set([ + ('000', 'first last'), + ])) + + +class AccountReadViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.read' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def get_users_extra(self): + return { + 'user1': create_user('user1', '001'), + } + + def setUp(self): + super().setUp() + + user1_acc = self.accounts['user1'] + team_acc = self.accounts['team'] + + # Dummy operations and operation groups + checkout = Checkout.objects.create( + created_by=team_acc, name="checkout", + valid_from=timezone.now(), + valid_to=timezone.now() + timezone.timedelta(days=365) + ) + opeg_data = [ + (timezone.now(), Decimal('10')), + (timezone.now() - timezone.timedelta(days=3), Decimal('3')), + ] + OperationGroup.objects.bulk_create([ + OperationGroup( + on_acc=user1_acc, checkout=checkout, at=at, is_cof=False, + amount=amount + ) + for (at, amount) in opeg_data + ]) + self.operation_groups = OperationGroup.objects.order_by("-amount") + Operation.objects.create( + group=self.operation_groups[0], + type=Operation.PURCHASE, + amount=Decimal('10') + ) + Operation.objects.create( + group=self.operation_groups[1], + type=Operation.PURCHASE, + amount=Decimal('3') + ) + + def test_ok(self): + """We can query the "Account - Read" page.""" + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_ok_self(self): + client = Client() + client.login(username='user1', password='user1') + r = client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class AccountUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.update' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/edit' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + # User + 'first_name': 'The first', + 'last_name': 'The last', + 'email': '', + # Group + 'groups[]': [], + # Account + 'trigramme': '051', + 'nickname': '', + 'promo': '', + # 'is_frozen': not checked + # Account password + 'pwd1': '', + 'pwd2': '', + } + + def get_users_extra(self): + return { + 'user1': create_user('user1', '001'), + 'team1': create_team('team1', '101', perms=[ + 'kfet.change_account', + ]), + } + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_get_ok_self(self): + client = Client() + client.login(username='user1', password='user1') + r = client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.account.read', args=['051'])) + + self.accounts['user1'].refresh_from_db() + self.users['user1'].refresh_from_db() + + self.assertInstanceExpected(self.accounts['user1'], { + 'first_name': 'The first', + 'last_name': 'The last', + 'trigramme': '051', + }) + + def test_post_ok_self(self): + client = Client() + client.login(username='user1', password='user1') + + post_data = { + 'first_name': 'The first', + 'last_name': 'The last', + } + + r = client.post(self.url, post_data) + self.assertRedirects(r, reverse('kfet.account.read', args=['001'])) + + self.accounts['user1'].refresh_from_db() + self.users['user1'].refresh_from_db() + + self.assertInstanceExpected(self.accounts['user1'], { + 'first_name': 'The first', + 'last_name': 'The last', + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class AccountGroupListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.group' + url_expected = '/k-fet/accounts/groups' + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.manage_perms']), + } + + def setUp(self): + super().setUp() + self.group1 = Group.objects.create(name='K-Fêt - Group1') + self.group2 = Group.objects.create(name='K-Fêt - Group2') + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + self.assertQuerysetEqual( + r.context['groups'], + map(repr, [self.group1, self.group2]), + ordered=False, + ) + + +class AccountGroupCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.group.create' + url_expected = '/k-fet/accounts/groups/new' + + http_methods = ['GET', 'POST'] + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.manage_perms']), + } + + @property + def post_data(self): + return { + 'name': 'The Group', + 'permissions': [ + str(self.perms['kfet.is_team'].pk), + str(self.perms['kfet.manage_perms'].pk), + ], + } + + def setUp(self): + super().setUp() + self.perms = get_perms( + 'kfet.is_team', + 'kfet.manage_perms', + ) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + r = self.client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.account.group')) + + group = Group.objects.get(name='K-Fêt The Group') + + self.assertQuerysetEqual( + group.permissions.all(), + map(repr, [ + self.perms['kfet.is_team'], + self.perms['kfet.manage_perms'], + ]), + ordered=False, + ) + + +class AccountGroupUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.group.update' + + http_methods = ['GET', 'POST'] + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + @property + def url_kwargs(self): + return {'pk': self.group.pk} + + @property + def url_expected(self): + return '/k-fet/accounts/groups/{}/edit'.format(self.group.pk) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.manage_perms']), + } + + @property + def post_data(self): + return { + 'name': 'The Group', + 'permissions': [ + str(self.perms['kfet.is_team'].pk), + str(self.perms['kfet.manage_perms'].pk), + ], + } + + def setUp(self): + super().setUp() + self.perms = get_perms( + 'kfet.is_team', + 'kfet.manage_perms', + ) + self.group = Group.objects.create(name='K-Fêt - Group') + self.group.permissions.set(self.perms.values()) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + r = self.client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.account.group')) + + self.group.refresh_from_db() + + self.assertEqual(self.group.name, 'K-Fêt The Group') + self.assertQuerysetEqual( + self.group.permissions.all(), + map(repr, [ + self.perms['kfet.is_team'], + self.perms['kfet.manage_perms'], + ]), + ordered=False, + ) + + +class AccountNegativeListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.negative' + url_expected = '/k-fet/accounts/negatives' + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.view_negs']), + } + + def setUp(self): + super().setUp() + account = self.accounts['user'] + account.balance = -5 + account.save() + account.update_negative() + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['negatives'], + map(repr, [self.accounts['user'].negative]), + ordered=False, + ) + + +class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.stat.operation.list' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/stat/operations/list' + + auth_user = 'user1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return {'user1': create_user('user1', '001')} + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + content = json.loads(r.content.decode('utf-8')) + + base_url = reverse('kfet.account.stat.operation', args=['001']) + + expected_stats = [{ + 'label': 'Derniers mois', + 'url': { + 'path': base_url, + 'query': { + 'scale_n_steps': ['7'], + 'scale_name': ['month'], + 'types': ["['purchase']"], + 'scale_last': ['True'], + }, + }, + }, { + 'label': 'Dernières semaines', + 'url': { + 'path': base_url, + 'query': { + 'scale_n_steps': ['7'], + 'scale_name': ['week'], + 'types': ["['purchase']"], + 'scale_last': ['True'], + }, + }, + }, { + 'label': 'Derniers jours', + 'url': { + 'path': base_url, + 'query': { + 'scale_n_steps': ['7'], + 'scale_name': ['day'], + 'types': ["['purchase']"], + 'scale_last': ['True'], + }, + }, + }] + + for stat, expected in zip(content['stats'], expected_stats): + expected_url = expected.pop('url') + self.assertUrlsEqual(stat['url'], expected_url) + self.assertDictContainsSubset(expected, stat) + + +class AccountStatOperationViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.stat.operation' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/stat/operations' + + auth_user = 'user1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return {'user1': create_user('user1', '001')} + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class AccountStatBalanceListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.stat.balance.list' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/stat/balance/list' + + auth_user = 'user1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return {'user1': create_user('user1', '001')} + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + content = json.loads(r.content.decode('utf-8')) + + base_url = reverse('kfet.account.stat.balance', args=['001']) + + expected_stats = [{ + 'label': 'Tout le temps', + 'url': base_url, + }, { + 'label': '1 an', + 'url': { + 'path': base_url, + 'query': {'last_days': ['365']}, + }, + }, { + 'label': '6 mois', + 'url': { + 'path': base_url, + 'query': {'last_days': ['183']}, + }, + }, { + 'label': '3 mois', + 'url': { + 'path': base_url, + 'query': {'last_days': ['90']}, + }, + }, { + 'label': '30 jours', + 'url': { + 'path': base_url, + 'query': {'last_days': ['30']}, + }, + }] + + for stat, expected in zip(content['stats'], expected_stats): + expected_url = expected.pop('url') + self.assertUrlsEqual(stat['url'], expected_url) + self.assertDictContainsSubset(expected, stat) + + +class AccountStatBalanceViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.stat.balance' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/stat/balance' + + auth_user = 'user1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return {'user1': create_user('user1', '001')} + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class CheckoutListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkout' + url_expected = '/k-fet/checkouts/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + self.checkout1 = Checkout.objects.create( + name='Checkout 1', + created_by=self.accounts['team'], + valid_from=self.now, + valid_to=self.now + timedelta(days=5), + ) + self.checkout2 = Checkout.objects.create( + name='Checkout 2', + created_by=self.accounts['team'], + valid_from=self.now + timedelta(days=10), + valid_to=self.now + timedelta(days=15), + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['checkouts'], + map(repr, [self.checkout1, self.checkout2]), + ordered=False, + ) + + +class CheckoutCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkout.create' + url_expected = '/k-fet/checkouts/new' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + 'name': 'Checkout', + 'valid_from': '2017-10-08 17:45:00', + 'valid_to': '2017-11-08 16:00:00', + 'balance': '3.14', + # 'is_protected': not checked + } + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.add_checkout']), + } + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + + checkout = Checkout.objects.get(name='Checkout') + self.assertRedirects(r, checkout.get_absolute_url()) + + self.assertInstanceExpected(checkout, { + 'name': 'Checkout', + 'valid_from': timezone.make_aware(datetime(2017, 10, 8, 17, 45)), + 'valid_to': timezone.make_aware(datetime(2017, 11, 8, 16, 00)), + 'balance': Decimal('3.14'), + 'is_protected': False, + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class CheckoutReadViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkout.read' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.checkout.pk} + + @property + def url_expected(self): + return '/k-fet/checkouts/{}'.format(self.checkout.pk) + + def setUp(self): + super().setUp() + + 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) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.context['checkout'], self.checkout) + + +class CheckoutUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkout.update' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + 'name': 'Checkout updated', + 'valid_from': '2018-01-01 08:00:00', + 'valid_to': '2018-07-01 16:00:00', + } + + @property + def url_kwargs(self): + return {'pk': self.checkout.pk} + + @property + def url_expected(self): + return '/k-fet/checkouts/{}/edit'.format(self.checkout.pk) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.change_checkout', + ]), + } + + def setUp(self): + super().setUp() + self.checkout = Checkout.objects.create( + name='Checkout', + valid_from=self.now, + valid_to=self.now + timedelta(days=5), + balance=Decimal('3.14'), + is_protected=False, + created_by=self.accounts['team'], + ) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, self.checkout.get_absolute_url()) + + self.checkout.refresh_from_db() + + self.assertInstanceExpected(self.checkout, { + 'name': 'Checkout updated', + 'valid_from': timezone.make_aware(datetime(2018, 1, 1, 8, 0, 0)), + 'valid_to': timezone.make_aware(datetime(2018, 7, 1, 16, 0, 0)), + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class CheckoutStatementListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkoutstatement' + url_expected = '/k-fet/checkouts/statements/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + self.checkout1 = Checkout.objects.create( + created_by=self.accounts['team'], + name='Checkout 1', + valid_from=self.now, + valid_to=self.now + timedelta(days=5), + ) + self.checkout2 = Checkout.objects.create( + created_by=self.accounts['team'], + name='Checkout 2', + valid_from=self.now + timedelta(days=10), + valid_to=self.now + timedelta(days=15), + ) + self.statement1 = CheckoutStatement.objects.create( + checkout=self.checkout1, + by=self.accounts['team'], + balance_old=5, + balance_new=0, + amount_taken=5, + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + expected_statements = ( + list(self.checkout1.statements.all()) + + list(self.checkout2.statements.all()) + ) + + self.assertQuerysetEqual( + r.context['checkoutstatements'], + map(repr, expected_statements), + ordered=False, + ) + + +class CheckoutStatementCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkoutstatement.create' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + # Let + 'balance_001': 0, 'balance_002': 0, 'balance_005': 0, + 'balance_01': 0, 'balance_02': 0, 'balance_05': 0, + 'balance_1': 1, 'balance_2': 0, 'balance_5': 0, + 'balance_10': 1, 'balance_20': 0, 'balance_50': 0, + 'balance_100': 1, 'balance_200': 0, 'balance_500': 0, + # Taken + 'taken_001': 0, 'taken_002': 0, 'taken_005': 0, + 'taken_01': 0, 'taken_02': 0, 'taken_05': 0, + 'taken_1': 2, 'taken_2': 0, 'taken_5': 0, + 'taken_10': 2, 'taken_20': 0, 'taken_50': 0, + 'taken_100': 2, 'taken_200': 0, 'taken_500': 0, + 'taken_cheque': 0, + # 'not_count': not checked + } + + @property + def url_kwargs(self): + return {'pk_checkout': self.checkout.pk} + + @property + def url_expected(self): + return '/k-fet/checkouts/{}/statements/add'.format(self.checkout.pk) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '001', perms=[ + 'kfet.add_checkoutstatement', + ]), + } + + def setUp(self): + super().setUp() + self.checkout = Checkout.objects.create( + name='Checkout', + created_by=self.accounts['team'], + balance=5, + valid_from=self.now, + valid_to=self.now + timedelta(days=5), + ) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + @mock.patch('django.utils.timezone.now') + def test_post_ok(self, mock_now): + self.now += timedelta(days=2) + mock_now.return_value = self.now + + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, self.checkout.get_absolute_url()) + + statement = CheckoutStatement.objects.get(at=self.now) + + self.assertInstanceExpected(statement, { + 'by': self.accounts['team1'], + 'checkout': self.checkout, + 'balance_old': Decimal('5'), + 'balance_new': Decimal('111'), + 'amount_taken': Decimal('222'), + 'amount_error': Decimal('328'), + 'at': self.now, + 'not_count': False, + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class CheckoutStatementUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkoutstatement.update' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + 'amount_taken': 3, + 'amount_error': 2, + 'balance_old': 8, + 'balance_new': 5, + # Taken + 'taken_001': 0, 'taken_002': 0, 'taken_005': 0, + 'taken_01': 0, 'taken_02': 0, 'taken_05': 0, + 'taken_1': 1, 'taken_2': 1, 'taken_5': 0, + 'taken_10': 0, 'taken_20': 0, 'taken_50': 0, + 'taken_100': 0, 'taken_200': 0, 'taken_500': 0, + 'taken_cheque': 0, + } + + @property + def url_kwargs(self): + return { + 'pk_checkout': self.checkout.pk, + 'pk': self.statement.pk, + } + + @property + def url_expected(self): + return '/k-fet/checkouts/{pk_checkout}/statements/{pk}/edit'.format( + pk_checkout=self.checkout.pk, + pk=self.statement.pk, + ) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.change_checkoutstatement', + ]), + } + + def setUp(self): + super().setUp() + self.checkout = Checkout.objects.create( + name='Checkout', + created_by=self.accounts['team'], + balance=5, + valid_from=self.now, + valid_to=self.now + timedelta(days=5), + ) + self.statement = CheckoutStatement.objects.create( + by=self.accounts['team'], + checkout=self.checkout, + balance_new=5, + balance_old=8, + amount_error=2, + amount_taken=5, + ) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + @mock.patch('django.utils.timezone.now') + def test_post_ok(self, mock_now): + self.now += timedelta(days=2) + mock_now.return_value = self.now + + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, self.checkout.get_absolute_url()) + + self.statement.refresh_from_db() + + self.assertInstanceExpected(self.statement, { + 'taken_1': 1, + 'taken_2': 1, + 'balance_new': 5, + 'balance_old': 8, + 'amount_error': 0, + 'amount_taken': 3, + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class ArticleCategoryListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.category' + url_expected = '/k-fet/categories/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + self.category1 = ArticleCategory.objects.create(name='Category 1') + self.category2 = ArticleCategory.objects.create(name='Category 2') + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + self.assertQuerysetEqual( + r.context['categories'], + map(repr, [self.category1, self.category2]), + ) + + +class ArticleCategoryUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.category.update' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.category.pk} + + @property + def url_expected(self): + return '/k-fet/categories/{}/edit'.format(self.category.pk) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.change_articlecategory', + ]), + } + + @property + def post_data(self): + return { + 'name': 'The Category', + # 'has_addcost': not checked + } + + def setUp(self): + super().setUp() + self.category = ArticleCategory.objects.create(name='Category') + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.category')) + + self.category.refresh_from_db() + + self.assertInstanceExpected(self.category, { + 'name': 'The Category', + 'has_addcost': False, + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class ArticleListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.article' + url_expected = '/k-fet/articles/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + category = ArticleCategory.objects.create(name='Category') + self.article1 = Article.objects.create( + name='Article 1', + category=category, + ) + self.article2 = Article.objects.create( + name='Article 2', + category=category, + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['articles'], + map(repr, [self.article1, self.article2]), + ) + + +class ArticleCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.article.create' + url_expected = '/k-fet/articles/new' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.add_article']), + } + + @property + def post_data(self): + return { + 'name': 'Article', + 'category': self.category.pk, + 'stock': 5, + 'price': '2.5', + } + + def setUp(self): + super().setUp() + self.category = ArticleCategory.objects.create(name='Category') + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + + article = Article.objects.get(name='Article') + + self.assertRedirects(r, article.get_absolute_url()) + + self.assertInstanceExpected(article, { + 'name': 'Article', + 'category': self.category, + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class ArticleReadViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.article.read' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.article.pk} + + @property + def url_expected(self): + return '/k-fet/articles/{}'.format(self.article.pk) + + def setUp(self): + super().setUp() + self.article = Article.objects.create( + name='Article', + category=ArticleCategory.objects.create(name='Category'), + stock=5, + price=Decimal('2.5'), + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.context['article'], self.article) + + +class ArticleUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.article.update' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.article.pk} + + @property + def url_expected(self): + return '/k-fet/articles/{}/edit'.format(self.article.pk) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.change_article', + ]), + } + + @property + def post_data(self): + return { + 'name': 'The Article', + 'category': self.article.category.pk, + 'is_sold': '1', + 'price': '3.5', + 'box_type': 'carton', + # 'hidden': not checked + } + + def setUp(self): + super().setUp() + self.category = ArticleCategory.objects.create(name='Category') + self.article = Article.objects.create( + name='Article', + category=self.category, + stock=5, + price=Decimal('2.5'), + ) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + + self.assertRedirects(r, self.article.get_absolute_url()) + + self.article.refresh_from_db() + + self.assertInstanceExpected(self.article, { + 'name': 'The Article', + 'price': Decimal('3.5'), + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.article.stat.sales.list' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.article.pk} + + @property + def url_expected(self): + return '/k-fet/articles/{}/stat/sales/list'.format(self.article.pk) + + def setUp(self): + super().setUp() + self.article = Article.objects.create( + name='Article', + category=ArticleCategory.objects.create(name='Category'), + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + content = json.loads(r.content.decode('utf-8')) + + base_url = reverse('kfet.article.stat.sales', args=[self.article.pk]) + + expected_stats = [ + { + 'label': 'Derniers mois', + 'url': { + 'path': base_url, + 'query': { + 'scale_n_steps': ['7'], + 'scale_name': ['month'], + 'scale_last': ['True'], + }, + }, + }, + { + 'label': 'Dernières semaines', + 'url': { + 'path': base_url, + 'query': { + 'scale_n_steps': ['7'], + 'scale_name': ['week'], + 'scale_last': ['True'], + }, + }, + }, + { + 'label': 'Derniers jours', + 'url': { + 'path': base_url, + 'query': { + 'scale_n_steps': ['7'], + 'scale_name': ['day'], + 'scale_last': ['True'], + }, + }, + }, + ] + + for stat, expected in zip(content['stats'], expected_stats): + expected_url = expected.pop('url') + self.assertUrlsEqual(stat['url'], expected_url) + self.assertDictContainsSubset(expected, stat) + + +class ArticleStatSalesViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.article.stat.sales' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.article.pk} + + @property + def url_expected(self): + return '/k-fet/articles/{}/stat/sales'.format(self.article.pk) + + def setUp(self): + super().setUp() + self.article = Article.objects.create( + name='Article', + category=ArticleCategory.objects.create(name='Category'), + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class KPsulViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.kpsul' + url_expected = '/k-fet/k-psul/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class KPsulCheckoutDataViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.kpsul.checkout_data' + url_expected = '/k-fet/k-psul/checkout_data' + + http_methods = ['POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + 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=5), + ) + + def test_ok(self): + r = self.client.post(self.url, {'pk': self.checkout.pk}) + self.assertEqual(r.status_code, 200) + + content = json.loads(r.content.decode('utf-8')) + + expected = { + 'name': 'Checkout', + 'balance': '10.00', + } + + self.assertDictContainsSubset(expected, content) + + self.assertSetEqual(set(content.keys()), set([ + 'balance', 'id', 'name', 'valid_from', 'valid_to', + 'last_statement_at', 'last_statement_balance', + 'last_statement_by_first_name', 'last_statement_by_last_name', + 'last_statement_by_trigramme', + ])) + + +class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.kpsul.perform_operations' + url_expected = '/k-fet/k-psul/perform_operations' + + http_methods = ['POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + pass + + +class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.kpsul.cancel_operations' + url_expected = '/k-fet/k-psul/cancel_operations' + + http_methods = ['POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + pass + + +class KPsulArticlesData(ViewTestCaseMixin, TestCase): + url_name = 'kfet.kpsul.articles_data' + url_expected = '/k-fet/k-psul/articles_data' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + category = ArticleCategory.objects.create(name='Catégorie') + self.article1 = Article.objects.create( + category=category, + name='Article 1', + ) + self.article2 = Article.objects.create( + category=category, + name='Article 2', + price=Decimal('2.5'), + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + content = json.loads(r.content.decode('utf-8')) + + articles = content['articles'] + + expected_list = [{ + 'category__name': 'Catégorie', + 'name': 'Article 1', + 'price': '0.00', + }, { + 'category__name': 'Catégorie', + 'name': 'Article 2', + 'price': '2.50', + }] + + for expected, article in zip(expected_list, articles): + self.assertDictContainsSubset(expected, article) + self.assertSetEqual(set(article.keys()), set([ + 'id', 'name', 'price', 'stock', + 'category_id', 'category__name', 'category__has_addcost', + ])) + + +class KPsulUpdateAddcost(ViewTestCaseMixin, TestCase): + url_name = 'kfet.kpsul.update_addcost' + url_expected = '/k-fet/k-psul/update_addcost' + + http_methods = ['POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + 'trigramme': '000', + 'amount': '0.5', + } + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.manage_addcosts', + ]), + } + + def test_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertEqual(r.status_code, 200) + + self.assertEqual( + kfet_config.addcost_for, + Account.objects.get(trigramme='000'), + ) + self.assertEqual(kfet_config.addcost_amount, Decimal('0.5')) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbidden(r) + + +class KPsulGetSettings(ViewTestCaseMixin, TestCase): + url_name = 'kfet.kpsul.get_settings' + url_expected = '/k-fet/k-psul/get_settings' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class HistoryJSONViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.history.json' + url_expected = '/k-fet/history.json' + + auth_user = 'user' + auth_forbidden = [None] + + def test_ok(self): + r = self.client.post(self.url) + self.assertEqual(r.status_code, 200) + + +class AccountReadJSONViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.read.json' + url_expected = '/k-fet/accounts/read.json' + + http_methods = ['POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.post(self.url, {'trigramme': '000'}) + self.assertEqual(r.status_code, 200) + + content = json.loads(r.content.decode('utf-8')) + + expected = { + 'name': 'first last', + 'trigramme': '000', + 'balance': '0.00', + } + self.assertDictContainsSubset(expected, content) + + self.assertSetEqual(set(content.keys()), set([ + 'balance', 'departement', 'email', 'id', 'is_cof', 'is_frozen', + 'name', 'nickname', 'promo', 'trigramme', + ])) + + +class SettingsListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.settings' + url_expected = '/k-fet/settings/' + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.see_config', + ]), + } + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class SettingsUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.settings.update' + url_expected = '/k-fet/settings/edit' + + http_methods = ['GET', 'POST'] + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + @property + def post_data(self): + return { + 'kfet_reduction_cof': '25', + 'kfet_addcost_amount': '0.5', + 'kfet_addcost_for': self.accounts['user'].pk, + 'kfet_overdraft_duration': '2 00:00:00', + 'kfet_overdraft_amount': '25', + 'kfet_cancel_duration': '00:20:00', + } + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.change_config', + ]), + } + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + r = self.client.post(self.url, self.post_data) + # Redirect is skipped because client may lack permissions. + self.assertRedirects( + r, + reverse('kfet.settings'), + fetch_redirect_response=False, + ) + + expected_config = { + 'reduction_cof': Decimal('25'), + 'addcost_amount': Decimal('0.5'), + 'addcost_for': self.accounts['user'], + 'overdraft_duration': timedelta(days=2), + 'overdraft_amount': Decimal('25'), + 'cancel_duration': timedelta(minutes=20), + } + + for key, expected in expected_config.items(): + self.assertEqual(getattr(kfet_config, key), expected) + + +class TransferListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.transfers' + url_expected = '/k-fet/transfers/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class TransferCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.transfers.create' + url_expected = '/k-fet/transfers/new' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class TransferPerformViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.transfers.perform' + url_expected = '/k-fet/transfers/perform' + + http_methods = ['POST'] + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + # Required + 'kfet.add_transfer', + # Convenience + 'kfet.perform_negative_operations', + ]), + } + + @property + def post_data(self): + return { + # General + 'comment': '', + # Formset management + 'form-TOTAL_FORMS': '10', + 'form-INITIAL_FORMS': '0', + 'form-MIN_NUM_FORMS': '1', + 'form-MAX_NUM_FORMS': '1000', + # Transfer 1 + 'form-0-from_acc': str(self.accounts['user'].pk), + 'form-0-to_acc': str(self.accounts['team'].pk), + 'form-0-amount': '3.5', + # Transfer 2 + 'form-1-from_acc': str(self.accounts['team'].pk), + 'form-1-to_acc': str(self.accounts['team1'].pk), + 'form-1-amount': '2.4', + } + + def test_ok(self): + r = self.client.post(self.url, self.post_data) + self.assertEqual(r.status_code, 200) + + user = self.accounts['user'] + user.refresh_from_db() + self.assertEqual(user.balance, Decimal('-3.5')) + + team = self.accounts['team'] + team.refresh_from_db() + self.assertEqual(team.balance, Decimal('1.1')) + + team1 = self.accounts['team1'] + team1.refresh_from_db() + self.assertEqual(team1.balance, Decimal('2.4')) + + +class TransferCancelViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.transfers.cancel' + url_expected = '/k-fet/transfers/cancel' + + http_methods = ['POST'] + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + # Convenience + 'kfet.perform_negative_operations', + ]), + } + + @property + def post_data(self): + return { + 'transfers[]': [self.transfer1.pk, self.transfer2.pk], + } + + def setUp(self): + super().setUp() + group = TransferGroup.objects.create() + self.transfer1 = Transfer.objects.create( + group=group, + from_acc=self.accounts['user'], + to_acc=self.accounts['team'], + amount='3.5', + ) + self.transfer2 = Transfer.objects.create( + group=group, + from_acc=self.accounts['team'], + to_acc=self.accounts['root'], + amount='2.4', + ) + + def test_ok(self): + r = self.client.post(self.url, self.post_data) + self.assertEqual(r.status_code, 200) + + user = self.accounts['user'] + user.refresh_from_db() + self.assertEqual(user.balance, Decimal('3.5')) + + team = self.accounts['team'] + team.refresh_from_db() + self.assertEqual(team.balance, Decimal('-1.1')) + + root = self.accounts['root'] + root.refresh_from_db() + self.assertEqual(root.balance, Decimal('-2.4')) + + +class InventoryListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.inventory' + url_expected = '/k-fet/inventaires/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + self.inventory = Inventory.objects.create( + by=self.accounts['team'], + ) + category = ArticleCategory.objects.create(name='Category') + article = Article.objects.create( + name='Article', + category=category, + ) + InventoryArticle.objects.create( + inventory=self.inventory, + article=article, + stock_old=5, + stock_new=0, + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + inventories = r.context['inventories'] + self.assertQuerysetEqual( + inventories, + map(repr, [self.inventory]), + ) + + +class InventoryCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.inventory.create' + url_expected = '/k-fet/inventaires/new' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.add_inventory', + ]), + } + + @property + def post_data(self): + return { + # Formset management + 'form-TOTAL_FORMS': '2', + 'form-INITIAL_FORMS': '2', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + # Article 1 + 'form-0-article': str(self.article1.pk), + 'form-0-stock_new': '5', + # Article 2 + 'form-1-article': str(self.article2.pk), + 'form-1-stock_new': '10', + } + + def setUp(self): + super().setUp() + category = ArticleCategory.objects.create(name='Category') + self.article1 = Article.objects.create( + category=category, + name='Article 1', + ) + self.article2 = Article.objects.create( + category=category, + name='Article 2', + ) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.inventory')) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class InventoryReadViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.inventory.read' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.inventory.pk} + + @property + def url_expected(self): + return '/k-fet/inventaires/{}'.format(self.inventory.pk) + + def setUp(self): + super().setUp() + self.inventory = Inventory.objects.create( + by=self.accounts['team'], + ) + category = ArticleCategory.objects.create(name='Category') + article = Article.objects.create( + name='Article', + category=category, + ) + InventoryArticle.objects.create( + inventory=self.inventory, + article=article, + stock_old=5, + stock_new=0, + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class OrderListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.order' + url_expected = '/k-fet/orders/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + category = ArticleCategory.objects.create(name='Category') + article = Article.objects.create(name='Article', category=category) + + supplier = Supplier.objects.create(name='Supplier') + SupplierArticle.objects.create(supplier=supplier, article=article) + + self.order = Order.objects.create(supplier=supplier) + OrderArticle.objects.create( + order=self.order, + article=article, + quantity_ordered=24, + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + orders = r.context['orders'] + self.assertQuerysetEqual( + orders, + map(repr, [self.order]), + ) + + +class OrderReadViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.order.read' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.order.pk} + + @property + def url_expected(self): + return '/k-fet/orders/{}'.format(self.order.pk) + + def setUp(self): + super().setUp() + category = ArticleCategory.objects.create(name='Category') + article = Article.objects.create(name='Article', category=category) + + supplier = Supplier.objects.create(name='Supplier') + SupplierArticle.objects.create(supplier=supplier, article=article) + + self.order = Order.objects.create(supplier=supplier) + OrderArticle.objects.create( + order=self.order, + article=article, + quantity_ordered=24, + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class SupplierUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.order.supplier.update' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.supplier.pk} + + @property + def url_expected(self): + return '/k-fet/orders/suppliers/{}/edit'.format(self.supplier.pk) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.change_supplier', + ]), + } + + @property + def post_data(self): + return { + 'name': 'The Supplier', + 'phone': '', + 'comment': '', + 'address': '', + 'email': '', + } + + def setUp(self): + super().setUp() + self.supplier = Supplier.objects.create(name='Supplier') + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.order')) + + self.supplier.refresh_from_db() + self.assertEqual(self.supplier.name, 'The Supplier') + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class OrderCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.order.new' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.supplier.pk} + + @property + def url_expected(self): + return '/k-fet/orders/suppliers/{}/new-order'.format(self.supplier.pk) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.add_order']), + } + + @property + def post_data(self): + return { + # Formset management + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '1', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + # Article + 'form-0-article': self.article.pk, + 'form-0-quantity_ordered': '20', + } + + def setUp(self): + super().setUp() + category = ArticleCategory.objects.create(name='Category') + self.article = Article.objects.create( + name='Article', + category=category, + ) + + self.supplier = Supplier.objects.create(name='Supplier') + SupplierArticle.objects.create( + supplier=self.supplier, + article=self.article, + ) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + @mock.patch('django.utils.timezone.now') + def test_post_ok(self, mock_now): + mock_now.return_value = self.now + + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + + order = Order.objects.get(at=self.now) + + self.assertRedirects(r, reverse('kfet.order.read', args=[order.pk])) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class OrderToInventoryViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.order.to_inventory' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.order.pk} + + @property + def url_expected(self): + return '/k-fet/orders/{}/to_inventory'.format(self.order.pk) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=[ + 'kfet.order_to_inventory', + ]), + } + + @property + def post_data(self): + return { + # Formset mangaement + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '1', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + # Article 1 + 'form-0-article': self.article.pk, + 'form-0-quantity_received': '20', + 'form-0-price_HT': '', + 'form-0-TVA': '', + 'form-0-rights': '', + } + + def setUp(self): + super().setUp() + category = ArticleCategory.objects.create(name='Category') + self.article = Article.objects.create( + name='Article', + category=category, + ) + + supplier = Supplier.objects.create(name='Supplier') + SupplierArticle.objects.create(supplier=supplier, article=self.article) + + self.order = Order.objects.create(supplier=supplier) + OrderArticle.objects.create( + order=self.order, + article=self.article, + quantity_ordered=24, + ) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + @mock.patch('django.utils.timezone.now') + def test_post_ok(self, mock_now): + mock_now.return_value = self.now + + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.order')) + + inventory = Inventory.objects.first() + + self.assertInstanceExpected(inventory, { + 'by': self.accounts['team1'], + 'at': self.now, + 'order': self.order, + }) + self.assertQuerysetEqual( + inventory.articles.all(), + map(repr, [self.article]), + ) + + compte = InventoryArticle.objects.get(article=self.article) + + self.assertInstanceExpected(compte, { + 'stock_old': 0, + 'stock_new': 20, + 'stock_error': 0, + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) diff --git a/kfet/tests/testcases.py b/kfet/tests/testcases.py new file mode 100644 index 00000000..aa2fb1b6 --- /dev/null +++ b/kfet/tests/testcases.py @@ -0,0 +1,347 @@ +from unittest import mock +from urllib.parse import parse_qs, urlparse + +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 + +from .utils import create_root, create_team, create_user + + +class TestCaseMixin: + """Extends TestCase for kfet application tests.""" + + 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 = '/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 assertForbiddenKfet(self, response, form_ctx='form'): + """ + Test that a response (retrieved with a Client) contains error due to + lack of kfet permissions. + + It checks that 'Permission refusée' is present in the non-field errors + of the form of response context at key 'form_ctx', or present in + messages. + + This should be used for pages which can be accessed by the kfet team + members, but require additionnal permission(s) to make an operation. + + """ + try: + self.assertEqual(response.status_code, 200) + try: + form = response.context[form_ctx] + self.assertIn("Permission refusée", form.non_field_errors()) + except (AssertionError, AttributeError, KeyError): + messages = [str(msg) for msg in response.context['messages']] + self.assertIn("Permission refusée", messages) + except AssertionError: + request = response.wsgi_request + raise AssertionError( + "%(http_method)s request at %(path)s should raise an error " + "for %(username)s user.\n" + "Cannot find any errors in non-field errors of form " + "'%(form_ctx)s', nor in messages." % { + 'http_method': request.method, + 'path': request.get_full_path(), + 'username': ( + "'%s'" % request.user + if request.user.is_authenticated + else 'anonymous' + ), + 'form_ctx': form_ctx, + } + ) + + def assertInstanceExpected(self, instance, expected): + """ + Test that the values of the attributes and without-argument methods of + 'instance' are equal to 'expected' pairs. + """ + for attr, expected_value in expected.items(): + value = getattr(instance, attr) + if callable(value): + value = value() + self.assertEqual(value, expected_value) + + 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, three users are created with their kfet account: + - 'user': a basic user without any permission, account trigramme: 000, + - 'team': a user with kfet.is_team permission, account trigramme: 100, + - 'root': a superuser, account trigramme: 200. + Their password is their username. + + One can create additionnal users with 'get_users_extra' method, or prevent + these 3 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. Similarly, their kfet account is + registered on 'accounts' attribute. + + 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() + + # These attributes register users and accounts instances. + self.users = {} + self.accounts = {} + + for label, user in dict(self.users_base, **self.users_extra).items(): + self.register_user(label, user) + + if self.auth_user: + self.client.force_login(self.users[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. + + """ + # Format desc: username, password, trigramme + return { + # user, user, 000 + 'user': create_user(), + # team, team, 100 + 'team': create_team(), + # root, root, 200 + 'root': create_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 + if hasattr(user.profile, 'account_kfet'): + self.accounts[label] = user.profile.account_kfet + + 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) diff --git a/kfet/tests/utils.py b/kfet/tests/utils.py new file mode 100644 index 00000000..f3222e14 --- /dev/null +++ b/kfet/tests/utils.py @@ -0,0 +1,188 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission + +from ..models import Account + + +User = get_user_model() + + +def _create_user_and_account(user_attrs, account_attrs, perms=None): + """ + Create a user and its account, and assign permissions to this user. + + Arguments + user_attrs (dict): User data (first name, last name, password...). + account_attrs (dict): Account data (department, kfet password...). + perms (list of str: 'app.perm'): These permissions will be assigned to + the created user. No permission are assigned by default. + + If 'password' is not given in 'user_attrs', username is used as password. + + If 'kfet.is_team' is in 'perms' and 'password' is not in 'account_attrs', + the account password is 'kfetpwd_'. + + """ + user_pwd = user_attrs.pop('password', user_attrs['username']) + user = User.objects.create(**user_attrs) + user.set_password(user_pwd) + user.save() + + account_attrs['cofprofile'] = user.profile + kfet_pwd = account_attrs.pop('password', 'kfetpwd_{}'.format(user_pwd)) + + account = Account.objects.create(**account_attrs) + + if perms is not None: + user = user_add_perms(user, perms) + + if 'kfet.is_team' in perms: + account.change_pwd(kfet_pwd) + account.save() + + return user + + +def create_user(username='user', trigramme='000', **kwargs): + """ + Create a user without any permission and its kfet account. + + username and trigramme are accepted as arguments (defaults to 'user' and + '000'). + + user_attrs, account_attrs and perms can be given as keyword arguments to + customize the user and its kfet account. + + # Default values + + User + * username: user + * password: user + * first_name: first + * last_name: last + * email: mail@user.net + Account + * trigramme: 000 + + """ + user_attrs = kwargs.setdefault('user_attrs', {}) + + user_attrs.setdefault('username', username) + user_attrs.setdefault('first_name', 'first') + user_attrs.setdefault('last_name', 'last') + user_attrs.setdefault('email', 'mail@user.net') + + account_attrs = kwargs.setdefault('account_attrs', {}) + account_attrs.setdefault('trigramme', trigramme) + + return _create_user_and_account(**kwargs) + + +def create_team(username='team', trigramme='100', **kwargs): + """ + Create a user, member of the kfet team, and its kfet account. + + username and trigramme are accepted as arguments (defaults to 'team' and + '100'). + + user_attrs, account_attrs and perms can be given as keyword arguments to + customize the user and its kfet account. + + # Default values + + User + * username: team + * password: team + * first_name: team + * last_name: member + * email: mail@team.net + Account + * trigramme: 100 + * kfet password: kfetpwd_team + + """ + user_attrs = kwargs.setdefault('user_attrs', {}) + + user_attrs.setdefault('username', username) + user_attrs.setdefault('first_name', 'team') + user_attrs.setdefault('last_name', 'member') + user_attrs.setdefault('email', 'mail@team.net') + + account_attrs = kwargs.setdefault('account_attrs', {}) + account_attrs.setdefault('trigramme', trigramme) + + perms = kwargs.setdefault('perms', []) + perms.append('kfet.is_team') + + return _create_user_and_account(**kwargs) + + +def create_root(username='root', trigramme='200', **kwargs): + """ + Create a superuser and its kfet account. + + username and trigramme are accepted as arguments (defaults to 'root' and + '200'). + + user_attrs, account_attrs and perms can be given as keyword arguments to + customize the user and its kfet account. + + # Default values + + User + * username: root + * password: root + * first_name: super + * last_name: user + * email: mail@root.net + * is_staff, is_superuser: True + Account + * trigramme: 200 + * kfet password: kfetpwd_root + + """ + user_attrs = kwargs.setdefault('user_attrs', {}) + + user_attrs.setdefault('username', username) + user_attrs.setdefault('first_name', 'super') + user_attrs.setdefault('last_name', 'user') + user_attrs.setdefault('email', 'mail@root.net') + user_attrs['is_superuser'] = user_attrs['is_staff'] = True + + account_attrs = kwargs.setdefault('account_attrs', {}) + account_attrs.setdefault('trigramme', trigramme) + + return _create_user_and_account(**kwargs) + + +def get_perms(*labels): + """Return Permission instances from a list of '.'.""" + perms = {} + for label in set(labels): + app_label, codename = label.split('.', 1) + perms[label] = Permission.objects.get( + content_type__app_label=app_label, + codename=codename, + ) + return perms + + +def user_add_perms(user, perms_labels): + """ + Add perms to a user. + + Args: + user (User instance) + perms (list of str 'app.perm_name') + + Returns: + The same user (refetched from DB to avoid missing perms) + + """ + perms = get_perms(*perms_labels) + user.user_permissions.add(*perms.values()) + + # If permissions have already been fetched for this user, we need to reload + # it to avoid using of the previous permissions cache. + # https://docs.djangoproject.com/en/dev/topics/auth/default/#permission-caching + return User.objects.get(pk=user.pk) diff --git a/kfet/urls.py b/kfet/urls.py index c762d237..96fd4ddf 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -1,16 +1,13 @@ -# -*- coding: utf-8 -*- - -from django.conf.urls import url +from django.conf.urls import include, url from django.contrib.auth.decorators import permission_required -from kfet import views -from kfet import autocomplete + +from kfet import autocomplete, views from kfet.decorators import teamkfet_required + urlpatterns = [ - url(r'^$', views.Home.as_view(), - name='kfet.home'), - url(r'^login/genericteam$', views.login_genericteam, - name='kfet.login.genericteam'), + url(r'^login/generic$', views.login_generic, + name='kfet.login.generic'), url(r'^history$', views.history, name='kfet.history'), @@ -189,15 +186,12 @@ urlpatterns = [ # Settings urls # ----- - url(r'^settings/$', - permission_required('kfet.change_settings') - (views.SettingsList.as_view()), + url(r'^settings/$', views.config_list, name='kfet.settings'), - url(r'^settings/edit$', - permission_required('kfet.change_settings') - (views.SettingsUpdate.as_view()), + url(r'^settings/edit$', views.config_update, name='kfet.settings.update'), + # ----- # Transfers urls # ----- @@ -242,3 +236,8 @@ urlpatterns = [ url(r'^orders/(?P\d+)/to_inventory$', views.order_to_inventory, name='kfet.order.to_inventory'), ] + +urlpatterns += [ + # K-Fêt Open urls + url('^open/', include('kfet.open.urls')), +] diff --git a/kfet/utils.py b/kfet/utils.py new file mode 100644 index 00000000..3d06bb0b --- /dev/null +++ b/kfet/utils.py @@ -0,0 +1,115 @@ +import math +import json + +from django.core.cache import cache +from django.core.serializers.json import DjangoJSONEncoder + +from channels.channel import Group +from channels.generic.websockets import JsonWebsocketConsumer + +from .config import kfet_config + + +def to_ukf(balance, is_cof=False): + """Convert euro to UKF.""" + subvention = kfet_config.subvention_cof + grant = (1 + subvention / 100) if is_cof else 1 + return math.floor(balance * 10 * grant) + +# Storage + +class CachedMixin: + """Object with cached properties. + + Attributes: + cached (dict): Keys are cached properties. Associated value is the + returned default by getters in case the key is missing from cache. + cache_prefix (str): Used to prefix keys in cache. + + """ + cached = {} + cache_prefix = '' + + def __init__(self, cache_prefix=None, *args, **kwargs): + super().__init__(*args, **kwargs) + if cache_prefix is not None: + self.cache_prefix = cache_prefix + + def cachekey(self, attr): + return '{}__{}'.format(self.cache_prefix, attr) + + def __getattr__(self, attr): + if attr in self.cached: + return cache.get(self.cachekey(attr), self.cached.get(attr)) + elif hasattr(super(), '__getattr__'): + return super().__getattr__(attr) + else: + raise AttributeError("can't get attribute") + + def __setattr__(self, attr, value): + if attr in self.cached: + cache.set(self.cachekey(attr), value) + elif hasattr(super(), '__setattr__'): + super().__setattr__(attr, value) + else: + raise AttributeError("can't set attribute") + + def clear_cache(self): + cache.delete_many([ + self.cachekey(attr) for attr in self.cached.keys() + ]) + + +# Consumers + +class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer): + """Custom Json Websocket Consumer. + + Encode to JSON with DjangoJSONEncoder. + + """ + + @classmethod + def encode_json(cls, content): + return json.dumps(content, cls=DjangoJSONEncoder) + + +class PermConsumerMixin: + """Add support to check permissions on consumers. + + Attributes: + perms_connect (list): Required permissions to connect to this + consumer. + + message.user is appended as argument to each connection_groups method call. + + """ + http_user = True # Enable message.user + perms_connect = [] + + def connect(self, message, **kwargs): + """Check permissions on connection.""" + if message.user.has_perms(self.perms_connect): + super().connect(message, **kwargs) + else: + self.close() + + def raw_connect(self, message, **kwargs): + # Same as original raw_connect method of JsonWebsocketConsumer + # We add user to connection_groups call. + groups = self.connection_groups(user=message.user, **kwargs) + for group in groups: + Group(group, channel_layer=message.channel_layer).add(message.reply_channel) + self.connect(message, **kwargs) + + def raw_disconnect(self, message, **kwargs): + # Same as original raw_connect method of JsonWebsocketConsumer + # We add user to connection_groups call. + groups = self.connection_groups(user=message.user, **kwargs) + for group in groups: + Group(group, channel_layer=message.channel_layer).discard(message.reply_channel) + self.disconnect(message, **kwargs) + + def connection_groups(self, user, **kwargs): + """`message.user` is available as `user` arg. Original behavior.""" + return super().connection_groups(user=user, **kwargs) diff --git a/kfet/views.py b/kfet/views.py index 6a8bb636..29f7411a 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import ast from urllib.parse import urlencode @@ -12,31 +10,30 @@ from django.views.generic.edit import CreateView, UpdateView from django.core.urlresolvers import reverse, reverse_lazy from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin -from django.contrib.auth import authenticate, login from django.contrib.auth.decorators import login_required, permission_required -from django.contrib.auth.models import User, Permission, Group +from django.contrib.auth.models import User, Permission from django.http import JsonResponse, Http404 from django.forms import formset_factory from django.db import transaction from django.db.models import F, Sum, Prefetch, Count from django.db.models.functions import Coalesce from django.utils import timezone -from django.utils.crypto import get_random_string from django.utils.decorators import method_decorator + from gestioncof.models import CofProfile from kfet.config import kfet_config from kfet.decorators import teamkfet_required from kfet.models import ( Account, Checkout, Article, AccountNegative, - CheckoutStatement, GenericTeamToken, Supplier, SupplierArticle, Inventory, + CheckoutStatement, Supplier, SupplierArticle, Inventory, InventoryArticle, Order, OrderArticle, Operation, OperationGroup, TransferGroup, Transfer, ArticleCategory) from kfet.forms import ( AccountTriForm, AccountBalanceForm, AccountNoTriForm, UserForm, CofForm, UserRestrictTeamForm, UserGroupForm, AccountForm, CofRestrictForm, AccountPwdForm, AccountNegativeForm, UserRestrictForm, AccountRestrictForm, - GroupForm, CheckoutForm, CheckoutRestrictForm, CheckoutStatementCreateForm, + CheckoutForm, CheckoutRestrictForm, CheckoutStatementCreateForm, CheckoutStatementUpdateForm, ArticleForm, ArticleRestrictForm, KPsulOperationGroupForm, KPsulAccountForm, KPsulCheckoutForm, KPsulOperationFormSet, AddcostForm, FilterHistoryForm, @@ -47,59 +44,14 @@ from collections import defaultdict from kfet import consumers from datetime import timedelta from decimal import Decimal -import django_cas_ng import heapq import statistics from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes, WeekScale +from .auth.views import ( # noqa + account_group, login_generic, AccountGroupCreate, AccountGroupUpdate, +) -class Home(TemplateView): - template_name = "kfet/home.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - articles = list( - Article.objects - .filter(is_sold=True, hidden=False) - .select_related('category') - .order_by('category__name') - ) - pressions, others = [], [] - while len(articles) > 0: - article = articles.pop() - if article.category.name == 'Pression': - pressions.append(article) - else: - others.append(article) - context['pressions'], context['articles'] = pressions, others - return context - - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super(TemplateView, self).dispatch(*args, **kwargs) - - -@teamkfet_required -def login_genericteam(request): - # Check si besoin de déconnecter l'utilisateur de CAS - profile, _ = CofProfile.objects.get_or_create(user=request.user) - need_cas_logout = False - if profile.login_clipper: - need_cas_logout = True - # Récupèration de la vue de déconnexion de CAS - # Ici, car request sera modifié après - logout_cas = django_cas_ng.views.logout(request) - - # Authentification du compte générique - token = GenericTeamToken.objects.create(token=get_random_string(50)) - user = authenticate(username="kfet_genericteam", token=token.token) - login(request, user) - - if need_cas_logout: - # Vue de déconnexion de CAS - return logout_cas - - return render(request, "kfet/login_genericteam.html") def put_cleaned_data_in_dict(dict, form): for field in form.cleaned_data: @@ -294,10 +246,10 @@ def get_account_create_forms(request=None, username=None, login_clipper=None, # Form créations if request: - user_form = UserForm(request.POST, initial=user_initial, from_clipper=True) + user_form = UserForm(request.POST, initial=user_initial) cof_form = CofForm(request.POST, initial=cof_initial) else: - user_form = UserForm(initial=user_initial, from_clipper=True) + user_form = UserForm(initial=user_initial) cof_form = CofForm(initial=cof_initial) # Protection (read-only) des champs username et login_clipper @@ -366,8 +318,9 @@ def account_read(request, trigramme): account = get_object_or_404(Account, trigramme=trigramme) # Checking permissions - if not request.user.has_perm('kfet.is_team') \ - and request.user != account.user: + if not account.readable or ( + not request.user.has_perm('kfet.is_team') and + request.user != account.user): raise PermissionDenied addcosts = ( @@ -530,37 +483,6 @@ def account_update(request, trigramme): }) -@permission_required('kfet.manage_perms') -def account_group(request): - user_pre = Prefetch( - 'user_set', - queryset=User.objects.select_related('profile__account_kfet'), - ) - groups = ( - Group.objects - .filter(name__icontains='K-Fêt') - .prefetch_related('permissions', user_pre) - ) - return render(request, 'kfet/account_group.html', { - 'groups': groups, - }) - - -class AccountGroupCreate(SuccessMessageMixin, CreateView): - model = Group - template_name = 'kfet/account_group_form.html' - form_class = GroupForm - success_message = 'Nouveau groupe : %(name)s' - success_url = reverse_lazy('kfet.account.group') - -class AccountGroupUpdate(UpdateView): - queryset = Group.objects.filter(name__icontains='K-Fêt') - template_name = 'kfet/account_group_form.html' - form_class = GroupForm - success_message = 'Groupe modifié : %(name)s' - success_url = reverse_lazy('kfet.account.group') - - class AccountNegativeList(ListView): queryset = ( AccountNegative.objects @@ -571,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 @@ -604,17 +526,9 @@ class CheckoutCreate(SuccessMessageMixin, CreateView): # Creating form.instance.created_by = self.request.user.profile.account_kfet - checkout = form.save() + 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) - - return super(CheckoutCreate, self).form_valid(form) + return super().form_valid(form) # Checkout - Read @@ -624,7 +538,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 @@ -643,7 +557,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 @@ -695,7 +609,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 @@ -711,7 +625,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 @@ -723,7 +637,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 @@ -735,7 +649,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 @@ -767,7 +681,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 @@ -790,6 +704,14 @@ class ArticleList(ListView): ) template_name = 'kfet/article.html' context_object_name = 'articles' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + articles = context[self.context_object_name] + context['nb_articles'] = len(articles) + context[self.context_object_name] = articles.filter(is_sold=True) + context['not_sold_articles'] = articles.filter(is_sold=False) + return context # Article - Create @@ -834,7 +756,7 @@ class ArticleCreate(SuccessMessageMixin, CreateView): ) # Creating - return super(ArticleCreate, self).form_valid(form) + return super().form_valid(form) # Article - Read @@ -844,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') @@ -896,7 +818,7 @@ class ArticleUpdate(SuccessMessageMixin, UpdateView): article=article, supplier=supplier) # Updating - return super(ArticleUpdate, self).form_valid(form) + return super().form_valid(form) # ----- @@ -937,30 +859,34 @@ def account_read_json(request): 'trigramme': account.trigramme } return JsonResponse(data) + @teamkfet_required def kpsul_checkout_data(request): pk = request.POST.get('pk', 0) if not pk: pk = 0 - data = (Checkout.objects + + data = ( + Checkout.objects .annotate( last_statement_by_first_name=F('statements__by__cofprofile__user__first_name'), last_statement_by_last_name=F('statements__by__cofprofile__user__last_name'), last_statement_by_trigramme=F('statements__by__trigramme'), last_statement_balance=F('statements__balance_new'), last_statement_at=F('statements__at')) - .values( - 'id', 'name', 'balance', 'valid_from', 'valid_to', - 'last_statement_balance', 'last_statement_at', - 'last_statement_by_trigramme', 'last_statement_by_last_name', - 'last_statement_by_first_name') .select_related( 'statements' 'statements__by', 'statements__by__cofprofile__user') .filter(pk=pk) .order_by('statements__at') - .last()) + .values( + 'id', 'name', 'balance', 'valid_from', 'valid_to', + 'last_statement_balance', 'last_statement_at', + 'last_statement_by_trigramme', 'last_statement_by_last_name', + 'last_statement_by_first_name') + .last() + ) if data is None: raise Http404 return JsonResponse(data) @@ -1388,13 +1314,13 @@ def history_json(request): # Construction de la requête (sur les opérations) pour le prefetch queryset_prefetch = Operation.objects.select_related( - 'canceled_by__trigramme', 'addcost_for__trigramme', - 'article__name') + 'article', 'canceled_by', 'addcost_for') # Construction de la requête principale - opegroups = (OperationGroup.objects - .prefetch_related(Prefetch('opes', queryset = queryset_prefetch)) - .select_related('on_acc__trigramme', 'valid_by__trigramme') + opegroups = ( + OperationGroup.objects + .prefetch_related(Prefetch('opes', queryset=queryset_prefetch)) + .select_related('on_acc', 'valid_by') .order_by('at') ) # Application des filtres @@ -1477,6 +1403,9 @@ class SettingsList(TemplateView): template_name = 'kfet/settings.html' +config_list = permission_required('kfet.see_config')(SettingsList.as_view()) + + class SettingsUpdate(SuccessMessageMixin, FormView): form_class = KFetConfigForm template_name = 'kfet/settings_update.html' @@ -1485,13 +1414,17 @@ class SettingsUpdate(SuccessMessageMixin, FormView): def form_valid(self, form): # Checking permission - if not self.request.user.has_perm('kfet.change_settings'): + if not self.request.user.has_perm('kfet.change_config'): form.add_error(None, 'Permission refusée') return self.form_invalid(form) form.save() return super().form_valid(form) +config_update = ( + permission_required('kfet.change_config')(SettingsUpdate.as_view()) +) + # ----- # Transfer views @@ -1776,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) @@ -1794,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 @@ -1908,9 +1841,12 @@ def order_create(request, pk): else: formset = cls_formset(initial=initial) + scale.label_fmt = "S-{rev_i}" + return render(request, 'kfet/order_create.html', { 'supplier': supplier, 'formset': formset, + 'scale': scale, }) @@ -1920,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) @@ -2057,6 +1993,7 @@ def order_to_inventory(request, pk): return render(request, 'kfet/order_to_inventory.html', { 'formset': formset, + 'order': order, }) class SupplierUpdate(SuccessMessageMixin, UpdateView): @@ -2073,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) # ========== @@ -2318,7 +2255,7 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView): ) context['charts'] = [{ - "color": "rgb(255, 99, 132)", + "color": "rgb(200, 20, 60)", "label": "Balance", "values": changes, }] @@ -2337,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) # ------------------------ @@ -2413,7 +2350,7 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): ventes = sum(ope['article_nb'] for ope in chunk) nb_ventes.append(ventes) - context['charts'] = [{"color": "rgb(255, 99, 132)", + context['charts'] = [{"color": "rgb(200, 20, 60)", "label": "NB items achetés", "values": nb_ventes}] return context @@ -2495,7 +2432,7 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): nb_accounts.append(sum_accounts) nb_liq.append(sum_liq) - context['charts'] = [{"color": "rgb(255, 99, 132)", + context['charts'] = [{"color": "rgb(200, 20, 60)", "label": "Toutes consommations", "values": nb_ventes}, {"color": "rgb(54, 162, 235)", diff --git a/manage.py b/manage.py old mode 100644 new mode 100755 index 7f4e79f6..094ec16f --- a/manage.py +++ b/manage.py @@ -3,7 +3,7 @@ import os import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cof.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cof.settings.local") from django.core.management import execute_from_command_line diff --git a/provisioning/apache.conf b/provisioning/apache.conf deleted file mode 100644 index db5bd602..00000000 --- a/provisioning/apache.conf +++ /dev/null @@ -1,39 +0,0 @@ - - ServerName default - DocumentRoot /var/www/html - - ProxyPreserveHost On - ProxyRequests Off - ProxyPass /static/ ! - ProxyPass /media/ ! - # Pour utiliser un sous-dossier (typiquement /gestion/), il faut faire a la - # place des lignes suivantes: - # - # RequestHeader set Daphne-Root-Path /gestion - # ProxyPass /gestion/ws/ ws://127.0.0.1:8001/ws/ - # ProxyPass /gestion http://127.0.0.1:8001/gestion - # ProxyPassReverse /gestion http://127.0.0.1:8001/gestion - # - # Penser egalement a changer les /static/ et /media/ dans la config apache - # ainsi que dans les settings django. - ProxyPass /ws/ ws://127.0.0.1:8001/ws/ - ProxyPass / http://127.0.0.1:8001/ - ProxyPassReverse / http://127.0.0.1:8001/ - - Alias /media /vagrant/media - Alias /static /var/www/static - - Order deny,allow - Allow from all - - - Order deny,allow - Allow from all - - - ErrorLog ${APACHE_LOG_DIR}/error.log - CustomLog ${APACHE_LOG_DIR}/access.log combined - - - -# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index 3dfb8b62..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. @@ -8,76 +11,64 @@ DBNAME="cof_gestion" DBPASSWD="4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4" # Installation de paquets utiles -apt-get update && apt-get install -y python3-pip python3-dev python3-venv \ - libmysqlclient-dev libjpeg-dev git redis-server python3-tk -pip install -U pip +apt-get update && apt-get upgrade -y +apt-get install -y python3-pip python3-dev python3-venv libpq-dev postgresql \ + postgresql-contrib libjpeg-dev nginx git redis-server -# Configuration et installation de mysql. Le mot de passe root est le même que -# le mot de passe pour l'utilisateur local - pour rappel, ceci est une instance -# locale de développement. -echo "mysql-server mysql-server/root_password password $DBPASSWD" | debconf-set-selections -echo "mysql-server mysql-server/root_password_again password $DBPASSWD" | debconf-set-selections +# Postgresql +sudo -u postgres createdb $DBNAME +sudo -u postgres createuser -SdR $DBUSER +sudo -u postgres psql -c "ALTER USER $DBUSER WITH PASSWORD '$DBPASSWD';" +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DBNAME TO $DBUSER;" -apt-get install -y mysql-server -mysql -uroot -p$DBPASSWD -e "CREATE DATABASE $DBNAME; GRANT ALL PRIVILEGES ON $DBNAME.* TO '$DBUSER'@'localhost' IDENTIFIED BY '$DBPASSWD'" -mysql -uroot -p$DBPASSWD -e "GRANT ALL PRIVILEGES ON test_$DBNAME.* TO '$DBUSER'@'localhost'" - -# Configuration de redis +# Redis REDIS_PASSWD="dummy" redis-cli CONFIG SET requirepass $REDIS_PASSWD redis-cli -a $REDIS_PASSWD CONFIG REWRITE -# Installation et configuration d'Apache -apt-get install -y apache2 -a2enmod proxy proxy_http proxy_wstunnel headers -cp /vagrant/provisioning/apache.conf /etc/apache2/sites-available/gestiocof.conf -a2ensite gestiocof -a2dissite 000-default -service apache2 restart -mkdir /var/www/static -chown -R ubuntu:www-data /var/www/static +# Contenu statique +mkdir -p /srv/gestiocof/media +mkdir -p /srv/gestiocof/static +chown -R ubuntu:www-data /srv/gestiocof + +# Nginx +ln -s -f /vagrant/provisioning/nginx.conf /etc/nginx/sites-enabled/gestiocof.conf +rm -f /etc/nginx/sites-enabled/default +systemctl reload nginx + +# Environnement virtuel python +sudo -H -u ubuntu python3 -m venv ~ubuntu/venv +sudo -H -u ubuntu ~ubuntu/venv/bin/pip install -U pip +sudo -H -u ubuntu ~ubuntu/venv/bin/pip install -r /vagrant/requirements-devel.txt + +# Préparation de Django +cd /vagrant +ln -s -f secret_example.py cof/settings/secret.py +sudo -H -u ubuntu \ + DJANGO_SETTINGS_MODULE='cof.settings.dev' \ + bash -c ". ~/venv/bin/activate && bash provisioning/prepare_django.sh" +/home/ubuntu/venv/bin/python manage.py collectstatic --noinput --settings cof.settings.dev + +# Installation du cron pour les mails de rappels +sudo -H -u ubuntu crontab provisioning/cron.dev + +# Daphne + runworker +cp /vagrant/provisioning/daphne.service /etc/systemd/system/daphne.service +cp /vagrant/provisioning/worker.service /etc/systemd/system/worker.service +systemctl enable daphne.service +systemctl enable worker.service +systemctl start daphne.service +systemctl start worker.service # Mise en place du .bash_profile pour tout configurer lors du `vagrant ssh` cat >> ~ubuntu/.bashrc <> /vagrant/rappels.log ; python /vagrant/manage.py sendrappels >> /vagrant/rappels.log 2>&1 -*/5 * * * * python /vagrant/manage.py manage_revente >> /vagrant/reventes.log 2>&1 +19 */12 * * * date >> /vagrant/rappels.log ; /ubuntu/home/venv/bin/python /vagrant/manage.py sendrappels >> /vagrant/rappels.log 2>&1 +*/5 * * * * /ubuntu/home/venv/bin/python /vagrant/manage.py manage_revente >> /vagrant/reventes.log 2>&1 diff --git a/provisioning/cron.md b/provisioning/cron.md index 840a8716..7aff775b 100644 --- a/provisioning/cron.md +++ b/provisioning/cron.md @@ -9,9 +9,9 @@ envoie les mails de rappels des spectacles à venir (sauf s'ils ont déjà été envoyés). - Un fois toutes les 12 heures me semble bien. -- Penser à utiliser le bon executable python (virtualenvs) et les bonnes - variables d'environnement si besoin. -- Garde les logs peut être une bonne idée. +- Penser à utiliser le bon executable python (virtualenvs) et le bon fichier de + settings pour Django. +- Garder les logs peut être une bonne idée. Exemple : voir le fichier `provisioning/cron.dev`. diff --git a/provisioning/daphne.service b/provisioning/daphne.service new file mode 100644 index 00000000..41327ce5 --- /dev/null +++ b/provisioning/daphne.service @@ -0,0 +1,16 @@ +Description="GestioCOF" +After=syslog.target +After=network.target + +[Service] +Type=simple +User=ubuntu +Group=ubuntu +TimeoutSec=300 +WorkingDirectory=/vagrant +Environment="DJANGO_SETTINGS_MODULE=cof.settings.dev" +ExecStart=/home/ubuntu/venv/bin/daphne -u /srv/gestiocof/gestiocof.sock \ + cof.asgi:channel_layer + +[Install] +WantedBy=multi-user.target diff --git a/provisioning/nginx.conf b/provisioning/nginx.conf new file mode 100644 index 00000000..015e1712 --- /dev/null +++ b/provisioning/nginx.conf @@ -0,0 +1,56 @@ +upstream gestiocof { + # Daphne listens on a unix socket + server unix:/srv/gestiocof/gestiocof.sock; +} + +server { + listen 80; + + server_name localhost; + root /srv/gestiocof/; + + # / → /gestion/ + # /gestion → /gestion/ + rewrite ^/$ /gestion/; + rewrite ^/gestion$ /gestion/; + + # Static files + location /static/ { + access_log off; + add_header Cache-Control "public"; + expires 7d; + } + + # Uploaded media + location /media/ { + access_log off; + add_header Cache-Control "public"; + expires 7d; + } + + location /gestion/ { + # A copy-paste of what we have in production + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-SSL-Client-Serial $ssl_client_serial; + proxy_set_header X-SSL-Client-Verify $ssl_client_verify; + proxy_set_header X-SSL-Client-S-DN $ssl_client_s_dn; + proxy_set_header Daphne-Root-Path /gestion; + + location /gestion/ws/ { + # See http://nginx.org/en/docs/http/websocket.html + proxy_buffering off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_pass http://gestiocof/ws/; + } + + location /gestion/ { + proxy_pass http://gestiocof; + } + } +} diff --git a/provisioning/prepare_django.sh b/provisioning/prepare_django.sh index 4ec1a70f..891108e8 100644 --- a/provisioning/prepare_django.sh +++ b/provisioning/prepare_django.sh @@ -1,9 +1,9 @@ #!/bin/bash -# Doit être lancé par bootstrap.sh -source ~/venv/bin/activate +# Stop if an error is encountered. +set -e + python manage.py migrate python manage.py loaddata gestion sites articles python manage.py loaddevdata python manage.py syncmails -python manage.py collectstatic --noinput diff --git a/provisioning/supervisor.conf b/provisioning/supervisor.conf deleted file mode 100644 index 5fe3c22b..00000000 --- a/provisioning/supervisor.conf +++ /dev/null @@ -1,20 +0,0 @@ -[program:worker] -command=/home/ubuntu/venv/bin/python /vagrant/manage.py runworker -directory=/vagrant/ -user=ubuntu -environment=DBUSER={DBUSER},DBNAME={DBNAME},DBPASSWD={DBPASSWD},DJANGO_SETTINGS_MODULE="cof.settings.dev" -autostart=true -autorestart=true -redirect_stderr=true -stopasgroup=true -redirect_stderr=true - -[program:interface] -command=/home/ubuntu/venv/bin/daphne -b 127.0.0.1 -p 8001 cof.asgi:channel_layer -environment=DBUSER={DBUSER},DBNAME={DBNAME},DBPASSWD={DBPASSWD},DJANGO_SETTINGS_MODULE="cof.settings.dev" -directory=/vagrant/ -redirect_stderr=true -autostart=true -autorestart=true -stopasgroup=true -user=ubuntu diff --git a/provisioning/worker.service b/provisioning/worker.service new file mode 100644 index 00000000..42836cfe --- /dev/null +++ b/provisioning/worker.service @@ -0,0 +1,16 @@ +[Unit] +Description="GestioCOF" +After=syslog.target +After=network.target + +[Service] +Type=simple +User=ubuntu +Group=ubuntu +TimeoutSec=300 +WorkingDirectory=/vagrant +Environment="DJANGO_SETTINGS_MODULE=cof.settings.dev" +ExecStart=/home/ubuntu/venv/bin/python manage.py runworker + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt index 29cb1bac..6310cecd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,26 +1,30 @@ configparser==3.5.0 -Django==1.8.* -django-autocomplete-light==2.3.3 +Django==1.11.* +django-autocomplete-light==3.1.3 django-autoslug==1.9.3 -django-cas-ng==3.5.5 +django-cas-ng==3.5.7 django-djconfig==0.5.3 -django-grappelli==2.8.1 -django-recaptcha==1.0.5 -mysqlclient==1.3.7 -Pillow==3.3.0 -six==1.10.0 -unicodecsv==0.14.1 -icalendar==3.10 -django-bootstrap-form==3.2.1 +django-recaptcha==1.4.0 +django-redis-cache==1.7.1 +icalendar +psycopg2 +Pillow +unicodecsv +django-bootstrap-form==3.3 asgiref==1.1.1 -daphne==1.2.0 +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 -channels==1.1.3 +channels==1.1.5 python-dateutil numpy==1.12.1 matplotlib==2.0.0 +wagtail==1.10.* +wagtailmenus==2.2.* +django-cors-headers==2.2.0 + +# Production tools +wheel 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 diff --git a/shared/tests/testcases.py b/shared/tests/testcases.py new file mode 100644 index 00000000..6967d9b7 --- /dev/null +++ b/shared/tests/testcases.py @@ -0,0 +1,335 @@ +import csv +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 + +import icalendar + +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) + + def load_from_csv_response(self, r): + decoded = r.content.decode('utf-8') + return list(csv.reader(decoded.split('\n')[:-1])) + + 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): + """ + 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) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utils/views/__init__.py b/utils/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utils/views/autocomplete.py b/utils/views/autocomplete.py new file mode 100644 index 00000000..ca50c63b --- /dev/null +++ b/utils/views/autocomplete.py @@ -0,0 +1,26 @@ +from django.db.models import Q + +from dal import autocomplete + + +class Select2QuerySetView(autocomplete.Select2QuerySetView): + model = None + search_fields = [] + + def get_queryset_filter(self): + q = self.q + filter_q = Q() + + if not q: + return filter_q + + words = q.split() + + for word in words: + for field in self.search_fields: + filter_q |= Q(**{'{}__icontains'.format(field): word}) + + return filter_q + + def get_queryset(self): + return self.model.objects.filter(self.get_queryset_filter())