diff --git a/.gitignore b/.gitignore index 0e991fe8..23b6a1b1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,9 @@ venv/ /src media/ *.log -.sass-cache/ \ No newline at end of file +.sass-cache/ +*.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 b6017577..01f4ead2 100644 --- a/README.md +++ b/README.md @@ -66,119 +66,65 @@ 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. +`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. + ### 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. +Vous pouvez opter 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, ...) : +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 python-pip python-dev libmysqlclient-dev redis-server + sudo apt-get install python3-pip python3-dev 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 : - virtualenv env -p $(which python3) + python3 -m venv venv -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 +Pour l'activer, il faut faire - . env/bin/activate + . venv/bin/activate dans le même dossier. Vous pouvez maintenant installer les dépendances Python depuis le fichier `requirements-devel.txt` : + pip install -U pip pip install -r requirements-devel.txt -Copiez le fichier `cof/settings_dev.py` dans `cof/settings.py`. +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: -#### Installation avec MySQL + ln -s secret_example.py cof/settings/secret.py -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 : +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 : - 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 articles - python manage.py loaddevdata + bash provisioning/prepare_django.sh Vous êtes prêts à développer ! Lancer GestioCOF en faisant @@ -188,7 +134,7 @@ Vous êtes prêts à développer ! Lancer GestioCOF en faisant 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 : @@ -197,6 +143,6 @@ Pour mettre à jour les modèles après une migration, il faut ensuite faire : ## 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/bda/admin.py b/bda/admin.py index 0cc66d43..6638ad45 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -import autocomplete_light from datetime import timedelta from custommail.shortcuts import send_mass_custom_mail @@ -9,6 +8,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 +26,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 +67,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 @@ -180,7 +191,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 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/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/models.py b/bda/models.py index 41462d70..73356038 100644 --- a/bda/models.py +++ b/bda/models.py @@ -6,12 +6,15 @@ 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( @@ -59,9 +62,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) @@ -71,7 +77,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) @@ -135,7 +141,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) @@ -149,7 +155,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") @@ -160,7 +166,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) @@ -176,8 +182,11 @@ DOUBLE_CHOICES = ( 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, @@ -204,8 +213,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): @@ -214,18 +226,25 @@ 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 à") + 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) @@ -312,37 +331,55 @@ class SpectacleRevente(models.Model): # Envoie un mail au gagnant et au vendeur winner = random.choice(inscrits) self.soldTo = winner - datatuple = [] + + mails = [] + 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(( + + c_mails_qs = CustomMail.objects.filter(shortname__in=[ + 'bda-revente-winner', 'bda-revente-loser', 'bda-revente-seller', - context, - settings.MAIL_DATA['revente']['FROM'], - [seller.user.email] - )) + ]) + + 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 - datatuple.append(( - 'bda-revente-loser', - new_context, - settings.MAIL_DATA['revente']['FROM'], - [inscrit.user.email] - )) - send_mass_custom_mail(datatuple) + + 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 diff --git a/bda/templates/bda/participants.html b/bda/templates/bda/participants.html index 85af4a2e..c3ff31d6 100644 --- a/bda/templates/bda/participants.html +++ b/bda/templates/bda/participants.html @@ -47,11 +47,11 @@
- +
Page d'envoi manuel des mails de rappel
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..d9242b3f --- /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.answered_mail = 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.py b/bda/tests/test_views.py similarity index 98% rename from bda/tests.py rename to bda/tests/test_views.py index 97a220c9..88dfd980 100644 --- a/bda/tests.py +++ b/bda/tests/test_views.py @@ -4,7 +4,7 @@ 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): diff --git a/bda/urls.py b/bda/urls.py index 876c84ea..6acc1746 100644 --- a/bda/urls.py +++ b/bda/urls.py @@ -32,6 +32,12 @@ urlpatterns = [ url(r'^spectacles/unpaid/(?P\d+)$', views.unpaid, name="bda-unpaid"), + url(r'^spectacles/autocomplete$', + views.spectacle_autocomplete, + name="bda-spectacle-autocomplete"), + url(r'^participants/autocomplete$', + views.participant_autocomplete, + name="bda-participant-autocomplete"), url(r'^liste-revente/(?P\d+)$', views.list_revente, name="bda-liste-revente"), diff --git a/bda/views.py b/bda/views.py index d6da2e9f..6c4edff8 100644 --- a/bda/views.py +++ b/bda/views.py @@ -33,6 +33,8 @@ from bda.forms import ( InscriptionInlineFormSet, ) +from utils.views.autocomplete import Select2QuerySetView + @cof_required def etat_places(request, tirage_id): @@ -782,9 +784,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 @@ -813,3 +815,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/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 0117c662..af344796 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -8,26 +8,45 @@ the local development server should be here. import os -# 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, KFETOPEN_TOKEN, + 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") + +RECAPTCHA_PUBLIC_KEY = import_secret("RECAPTCHA_PUBLIC_KEY") +RECAPTCHA_PRIVATE_KEY = import_secret("RECAPTCHA_PRIVATE_KEY") + +KFETOPEN_TOKEN = import_secret("KFETOPEN_TOKEN") +LDAP_SERVER_URL = import_secret("LDAP_SERVER_URL") BASE_DIR = os.path.dirname( @@ -38,17 +57,22 @@ BASE_DIR = os.path.dirname( # Application definition INSTALLED_APPS = [ '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', @@ -74,17 +98,18 @@ INSTALLED_APPS = [ 'modelcluster', 'taggit', 'wagtail_modeltranslation', + 'kfet.auth', 'kfet.cms', 'gestioncof.cms', ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ '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', @@ -107,13 +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', ], }, @@ -122,7 +147,7 @@ TEMPLATES = [ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.mysql', + 'ENGINE': 'django.db.backends.postgresql_psycopg2', 'NAME': DBNAME, 'USER': DBUSER, 'PASSWORD': DBPASSWD, @@ -181,7 +206,7 @@ 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_USE_SSL = True diff --git a/cof/settings/dev.py b/cof/settings/dev.py index ffd34c7d..6e1f6b11 100644 --- a/cof/settings/dev.py +++ b/cof/settings/dev.py @@ -3,9 +3,8 @@ 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 EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' @@ -18,9 +17,9 @@ DEBUG = True # --- 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/' @@ -28,26 +27,6 @@ MEDIA_URL = '/media/' # Debug tool bar # --- -# "Versions" panel of django-debug-toolbar <=1.8 is not compatible with -# wagtailmenus. -# See https://github.com/jazzband/django-debug-toolbar/issues/922 -# TODO: Bug should be fixed in ddt 1.9 (not released yet). When fixed, this -# declaration may be removed. -DEBUG_TOOLBAR_PANELS = [ - 'debug_toolbar.panels.timer.TimerPanel', - 'debug_toolbar.panels.settings.SettingsPanel', - 'debug_toolbar.panels.headers.HeadersPanel', - 'debug_toolbar.panels.request.RequestPanel', - 'debug_toolbar.panels.sql.SQLPanel', - 'debug_toolbar.panels.staticfiles.StaticFilesPanel', - 'debug_toolbar.panels.templates.TemplatesPanel', - 'debug_toolbar.panels.cache.CachePanel', - 'debug_toolbar.panels.signals.SignalsPanel', - 'debug_toolbar.panels.logging.LoggingPanel', - 'debug_toolbar.panels.redirects.RedirectsPanel', -] - - def show_toolbar(request): """ On ne veut pas la vérification de INTERNAL_IPS faite par la debug-toolbar @@ -58,10 +37,11 @@ def show_toolbar(request): return DEBUG INSTALLED_APPS += ["debug_toolbar", "debug_panel"] -MIDDLEWARE_CLASSES = ( - ["debug_panel.middleware.DebugPanelMiddleware"] - + MIDDLEWARE_CLASSES -) + +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..2ffdf02f 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 DEBUG = False @@ -16,11 +17,14 @@ 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" diff --git a/cof/settings/secret_example.py b/cof/settings/secret_example.py index a1d35b68..e966565a 100644 --- a/cof/settings/secret_example.py +++ b/cof/settings/secret_example.py @@ -1,10 +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 f62d5f01..e6e5d313 100644 --- a/cof/urls.py +++ b/cof/urls.py @@ -4,8 +4,6 @@ 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 @@ -24,7 +22,6 @@ from gestioncof.urls import export_patterns, petitcours_patterns, \ clubs_patterns from gestioncof.autocomplete import autocomplete -autocomplete_light.autodiscover() admin.autodiscover() urlpatterns = [ @@ -49,18 +46,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, 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"), @@ -70,7 +71,8 @@ urlpatterns = [ name="empty-registration"), # Autocompletion url(r'^autocomplete/registration$', autocomplete), - url(r'^autocomplete/', include('autocomplete_light.urls')), + 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')), @@ -78,10 +80,11 @@ 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_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), url(r'^utile_cof/diff_cof$', gestioncof_views.liste_diffcof), url(r'^utile_bda/bda_revente$', gestioncof_views.liste_bdarevente), diff --git a/gestioncof/admin.py b/gestioncof/admin.py index 0d7d7143..51969822 100644 --- a/gestioncof/admin.py +++ b/gestioncof/admin.py @@ -13,7 +13,7 @@ from django.core.urlresolvers import reverse from django.utils.safestring import mark_safe from django.db.models import Q -import autocomplete_light +from dal.autocomplete import ModelSelect2 def add_link_field(target_model='', field='', link_text=str, @@ -217,8 +217,16 @@ def user_str(self): 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=[]) + form = EventRegistrationAdminForm + list_display = ('__str__', 'event', 'user', 'paid') list_filter = ('paid',) search_fields = ('user__username', 'user__first_name', 'user__last_name', diff --git a/gestioncof/autocomplete.py b/gestioncof/autocomplete.py index 3532525d..968398fd 100644 --- a/gestioncof/autocomplete.py +++ b/gestioncof/autocomplete.py @@ -58,7 +58,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/forms.py b/gestioncof/forms.py index 793f14e5..2124b7c8 100644 --- a/gestioncof/forms.py +++ b/gestioncof/forms.py @@ -3,7 +3,6 @@ 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.core.validators import MinLengthValidator from djconfig.forms import ConfigForm @@ -197,9 +196,6 @@ class RegistrationUserForm(forms.ModelForm): super(RegistrationUserForm, self).__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") diff --git a/gestioncof/management/data/custommail.json b/gestioncof/management/data/custommail.json index 9ee9b1ea..590ebf18 100644 --- a/gestioncof/management/data/custommail.json +++ b/gestioncof/management/data/custommail.json @@ -1,587 +1,600 @@ [ { - "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", - "pk": 3, "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" - } + "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 le signaler qu'une place vient d'\u00eatre mise en vente." + }, + "pk": 3 }, { "model": "custommail.custommail", - "pk": 4, "fields": { "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-buy-revente\" show.id %}, \u00e0 la disposition de tous.\r\n\r\nChaleureusement,\r\nLe BdA", + "description": "Notification signalant qu'une place est au shotgun aux personnes int\u00e9ress\u00e9es." + }, + "pk": 4 }, { "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/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 f1ad35e1..0d816155 100644 --- a/gestioncof/models.py +++ b/gestioncof/models.py @@ -8,23 +8,6 @@ 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")), @@ -32,8 +15,47 @@ TYPE_COMMENT_FIELD = ( 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) phone = models.CharField("Téléphone", max_length=20, blank=True) occupation = models.CharField(_("Occupation"), @@ -111,7 +133,10 @@ class Event(models.Model): 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") @@ -125,9 +150,14 @@ class EventCommentField(models.Model): 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): @@ -135,7 +165,10 @@ class EventCommentValue(models.Model): 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) @@ -147,7 +180,10 @@ class EventOption(models.Model): 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: @@ -159,8 +195,8 @@ class EventOptionChoice(models.Model): 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) @@ -188,7 +224,10 @@ class Survey(models.Model): 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) @@ -200,7 +239,10 @@ class SurveyQuestion(models.Model): 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: @@ -211,8 +253,8 @@ class SurveyQuestionAnswer(models.Model): 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") @@ -228,7 +270,7 @@ class SurveyAnswer(models.Model): 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_models.py b/gestioncof/petits_cours_models.py index 753e8674..bf2cfde9 100644 --- a/gestioncof/petits_cours_models.py +++ b/gestioncof/petits_cours_models.py @@ -35,8 +35,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 +87,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 +132,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 +157,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 +172,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 087c9cef..52a29883 100644 --- a/gestioncof/petits_cours_views.py +++ b/gestioncof/petits_cours_views.py @@ -1,7 +1,6 @@ # -*- 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 @@ -13,6 +12,7 @@ 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 ( @@ -287,7 +287,7 @@ def _traitement_post(request, demande): 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", 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/gestioncof/base_header.html b/gestioncof/templates/gestioncof/base_header.html index 21441875..e5f757a7 100644 --- a/gestioncof/templates/gestioncof/base_header.html +++ b/gestioncof/templates/gestioncof/base_header.html @@ -3,7 +3,7 @@ {% block content %}
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/home.html b/gestioncof/templates/home.html index acc04f30..65c4ba5e 100644 --- a/gestioncof/templates/home.html +++ b/gestioncof/templates/home.html @@ -14,7 +14,7 @@
@@ -24,7 +24,7 @@
@@ -69,11 +69,11 @@

Divers

{% endif %} @@ -86,16 +86,16 @@

Général

  • Administration générale
  • Demandes de petits cours
  • -
  • Inscription d'un nouveau membre
  • +
  • Inscription d'un nouveau membre
  • Gestion des clubs
  • @@ -120,8 +120,8 @@

    Liens utiles

    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/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/urls.py b/gestioncof/urls.py index 9a562e7e..2be609b3 100644 --- a/gestioncof/urls.py +++ b/gestioncof/urls.py @@ -10,7 +10,7 @@ export_patterns = [ 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/(?P.+)$', views.export_mega_bytype), url(r'^mega$', views.export_mega), ] @@ -36,19 +36,23 @@ 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) ] clubs_patterns = [ diff --git a/gestioncof/views.py b/gestioncof/views.py index fffd5751..5dfee83f 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -20,6 +20,8 @@ from django.contrib import messages 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 from gestioncof.models import Event, EventRegistration, EventOption, \ @@ -54,8 +56,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'] @@ -398,7 +400,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() @@ -437,12 +438,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 @@ -580,7 +579,7 @@ def export_members(request): writer = unicodecsv.writer(response) for profile in CofProfile.objects.filter(is_cof=True).all(): user = profile.user - bits = [profile.id, 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([str(bit) for bit in bits]) @@ -599,7 +598,7 @@ 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.id, + profile.phone, user.id, profile.comments if profile.comments else "", comments] writer.writerow([str(bit) for bit in bits]) @@ -609,13 +608,13 @@ def csv_export_mega(filename, qs): @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 @@ -627,50 +626,52 @@ def export_mega_remarksonly(request): 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 @@ -787,3 +788,18 @@ class ConfigUpdate(FormView): 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/kfet/apps.py b/kfet/apps.py index 3dd2c0e8..4f114c37 100644 --- a/kfet/apps.py +++ b/kfet/apps.py @@ -11,7 +11,6 @@ class KFetConfig(AppConfig): 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..c6ad21b2 --- /dev/null +++ b/kfet/auth/backends.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +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..48d9c4ee --- /dev/null +++ b/kfet/auth/middleware.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +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..0c8b25d3 --- /dev/null +++ b/kfet/auth/tests.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- +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..c4886180 100644 --- a/kfet/autocomplete.py +++ b/kfet/autocomplete.py @@ -76,7 +76,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 +106,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/fixtures/kfet_wagtail_17_05.json b/kfet/cms/fixtures/kfet_wagtail_17_05.json index f6a46c30..66ac7040 100644 --- a/kfet/cms/fixtures/kfet_wagtail_17_05.json +++ b/kfet/cms/fixtures/kfet_wagtail_17_05.json @@ -53,7 +53,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -83,7 +83,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -113,7 +113,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -143,7 +143,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -173,7 +173,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -203,7 +203,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -233,7 +233,7 @@ "page" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -263,7 +263,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -681,7 +681,7 @@ "fields": { "created_at": "2017-05-30T04:20:00.000Z", "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "collection": 2, "title": "K-F\u00eat - Plan d'acc\u00e8s", @@ -694,7 +694,7 @@ "fields": { "created_at": "2017-05-30T04:20:00.000Z", "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "collection": 2, "title": "K-F\u00eat - Demande d'autorisation", @@ -707,7 +707,7 @@ "fields": { "created_at": "2017-05-30T04:20:00.000Z", "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "collection": 2, "title": "K-F\u00eat - Trait\u00e9 de Flipper Th\u00e9orique", @@ -730,7 +730,7 @@ "title": "K-F\u00eat - Amazon Hunt", "width": 200, "uploaded_by_user": [ - "root" + "kfet_genericteam" ] } }, @@ -750,7 +750,7 @@ "title": "K-F\u00eat - Fun Machine", "width": 200, "uploaded_by_user": [ - "root" + "kfet_genericteam" ] } }, @@ -767,7 +767,7 @@ "title": "Hugo Manet", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -787,7 +787,7 @@ "title": "Lisa Gourdon", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -807,7 +807,7 @@ "title": "Pierre Quesselaire", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -827,7 +827,7 @@ "title": "Thibault Scoquard", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -847,7 +847,7 @@ "title": "Arnaud Fanthomme", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -867,7 +867,7 @@ "title": "Vincent Balerdi", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -887,7 +887,7 @@ "title": "Nathana\u00ebl Willaime", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -907,7 +907,7 @@ "title": "\u00c9lisabeth Miller", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -927,7 +927,7 @@ "title": "Arthur Lesage", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -947,7 +947,7 @@ "title": "Sarah Asset", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -967,7 +967,7 @@ "title": "Alexandre Legrand", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -987,7 +987,7 @@ "title": "\u00c9tienne Baudel", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1007,7 +1007,7 @@ "title": "Marine Snape", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1027,7 +1027,7 @@ "title": "Anatole Gosset", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1047,7 +1047,7 @@ "title": "Jacko Rastikian", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1067,7 +1067,7 @@ "title": "Alexandre Jannaud", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1087,7 +1087,7 @@ "title": "Aur\u00e9lien Delobelle", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1107,7 +1107,7 @@ "title": "Sylvain Douteau", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1127,7 +1127,7 @@ "title": "Rapha\u00ebl Lescanne", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1147,7 +1147,7 @@ "title": "Romain Gourvil", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1167,7 +1167,7 @@ "title": "Marie Labeye", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1187,7 +1187,7 @@ "title": "Oscar Blumberg", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1207,7 +1207,7 @@ "title": "Za\u00efd Allybokus", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1227,7 +1227,7 @@ "title": "Damien Garreau", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1247,7 +1247,7 @@ "title": "Andr\u00e9a Londono-Lopez", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1267,7 +1267,7 @@ "title": "Tristan Roussel", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1287,7 +1287,7 @@ "title": "Guillaume Vernade", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1307,7 +1307,7 @@ "title": "Lucas Mercier", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1327,7 +1327,7 @@ "title": "Fran\u00e7ois Maillot", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1347,7 +1347,7 @@ "title": "Fabrice Catoire", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, diff --git a/kfet/cms/migrations/0001_initial.py b/kfet/cms/migrations/0001_initial.py index 951637c7..ed0b0948 100644 --- a/kfet/cms/migrations/0001_initial.py +++ b/kfet/cms/migrations/0001_initial.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='KFetPage', fields=[ - ('page_ptr', models.OneToOneField(serialize=False, primary_key=True, parent_link=True, auto_created=True, to='wagtailcore.Page')), + ('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')), diff --git a/kfet/context_processors.py b/kfet/context_processors.py index 4c7b4fe4..04feec81 100644 --- a/kfet/context_processors.py +++ b/kfet/context_processors.py @@ -1,18 +1,7 @@ # -*- 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/forms.py b/kfet/forms.py index 5190689d..963e4254 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -5,10 +5,8 @@ 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.forms import modelformset_factory, widgets +from django.contrib.auth.models import User +from django.forms import modelformset_factory from django.utils import timezone from djconfig.forms import ConfigForm @@ -19,6 +17,8 @@ from kfet.models import ( TransferGroup, Supplier) from gestioncof.models import CofProfile +from .auth.forms import UserGroupForm # noqa + # ----- # Widgets @@ -110,21 +110,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'] @@ -134,49 +129,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 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 - - -class GroupForm(forms.ModelForm): - permissions = KFetPermissionsField() - - 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 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/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/models.py b/kfet/models.py index ec146ad9..deee76eb 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -1,18 +1,21 @@ # -*- coding: utf-8 -*- 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 .auth import KFET_GENERIC_TRIGRAMME +from .auth.models import GenericTeamToken # noqa from .config import kfet_config from .utils import to_ukf @@ -33,6 +36,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() @@ -75,6 +95,8 @@ class Account(models.Model): ('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): @@ -236,10 +258,9 @@ class Account(models.Model): self.cofprofile = cof super(Account, self).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 @@ -522,21 +543,24 @@ class InventoryArticle(models.Model): self.stock_error = self.stock_new - self.stock_old super(InventoryArticle, self).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) @@ -710,7 +734,3 @@ class Operation(models.Model): return templates[self.type].format(nb=self.article_nb, article=self.article, amount=self.amount) - - -class GenericTeamToken(models.Model): - token = models.CharField(max_length = 50, unique = True) diff --git a/kfet/open/open.py b/kfet/open/open.py index 7fd90c21..82d6217a 100644 --- a/kfet/open/open.py +++ b/kfet/open/open.py @@ -85,7 +85,7 @@ class OpenKfet(CachedMixin, object): 'admin_status': self.admin_status(status), 'force_close': self.force_close, } - return base, {**base, **restrict} + return base, dict(base, **restrict) def export(self, user): """Export internal state for a given user. diff --git a/kfet/open/static/kfetopen/kfet-open.css b/kfet/open/static/kfetopen/kfet-open.css index d44318cd..a7068626 100644 --- a/kfet/open/static/kfetopen/kfet-open.css +++ b/kfet/open/static/kfetopen/kfet-open.css @@ -14,10 +14,16 @@ .kfetopen .base { height: 50px; - padding: 15px; + max-width: 16px; - display: inline-flex; + margin-left: 5px; + margin-right: 5px; + + display: flex; + flex-wrap: wrap; + align-content: center; align-items: center; + justify-content: center; } .kfetopen .details { @@ -34,10 +40,23 @@ height: 10px; border-radius: 50%; transition: background 0.15s; + margin: 3px; } .kfetopen .warning { - margin-left: 15px; + 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 { diff --git a/kfet/open/static/kfetopen/kfet-open.js b/kfet/open/static/kfetopen/kfet-open.js index b86cc5bc..74f18d8a 100644 --- a/kfet/open/static/kfetopen/kfet-open.js +++ b/kfet/open/static/kfetopen/kfet-open.js @@ -74,10 +74,10 @@ OpenKfet.prototype = { if (this.admin) { this.add_class(this.admin_status); if (this.force_close) { - this.dom.warning.addClass('in'); + this.dom.warning.show().addClass('in'); this.dom.force_close_btn.html(this.force_text['deactivate']); } else { - this.dom.warning.removeClass('in'); + this.dom.warning.removeClass('in').hide(); this.dom.force_close_btn.html(this.force_text['activate']); } } diff --git a/kfet/open/tests.py b/kfet/open/tests.py index 1d6d5529..476eb6c0 100644 --- a/kfet/open/tests.py +++ b/kfet/open/tests.py @@ -1,5 +1,6 @@ import json from datetime import timedelta +from unittest import mock from django.contrib.auth.models import AnonymousUser, Permission, User from django.test import Client @@ -118,6 +119,11 @@ 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'), @@ -194,7 +200,8 @@ class OpenKfetConsumerTest(ChannelTestCase): OpenKfetConsumer.group_send('kfet.open.team', {'test': 'plop'}) self.assertIsNone(c.receive()) - def test_team_user(self): + @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') @@ -224,6 +231,11 @@ 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) diff --git a/kfet/signals.py b/kfet/signals.py deleted file mode 100644 index c677ac9c..00000000 --- a/kfet/signals.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- - -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 - - -@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') and - hasattr(request, 'GET') and - 'k-fet' in request.GET.get('next', '')): - messages.info(request, mark_safe( - '' - ' Connexion en utilisateur partagé ?' - '' - .format(reverse('kfet.login.genericteam')) - )) diff --git a/kfet/static/kfet/js/kfet.js b/kfet/static/kfet/js/kfet.js index b34f2005..75b80b04 100644 --- a/kfet/static/kfet/js/kfet.js +++ b/kfet/static/kfet/js/kfet.js @@ -1,22 +1,33 @@ -$(document).ready(function() { - 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)); +/** + * CSRF Token + */ + +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 */ @@ -199,3 +210,28 @@ 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(); +} diff --git a/kfet/templates/kfet/base_nav.html b/kfet/templates/kfet/base_nav.html index ba582bcf..f4c07e05 100644 --- a/kfet/templates/kfet/base_nav.html +++ b/kfet/templates/kfet/base_nav.html @@ -1,10 +1,12 @@ -{% load static %} +{% load i18n static %} {% load wagtailcore_tags %} +{% slugurl "kfet" as kfet_home_url %} + - - diff --git a/kfet/templates/kfet/confirm_form.html b/kfet/templates/kfet/confirm_form.html new file mode 100644 index 00000000..1cffd171 --- /dev/null +++ b/kfet/templates/kfet/confirm_form.html @@ -0,0 +1,20 @@ +{% extends "kfet/base_form.html" %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} +{% block header %}{% endblock %} + +{% block main-class %}main-bg main-padding text-center{% endblock %} +{% block main-size %}col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3{% endblock %} + +{% block main %} + + +

    + {{ text }} +

    + + {% csrf_token %} + + +{% endblock %} diff --git a/kfet/templates/kfet/login_genericteam.html b/kfet/templates/kfet/login_genericteam.html deleted file mode 100644 index d2f8eca0..00000000 --- a/kfet/templates/kfet/login_genericteam.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'kfet/base.html' %} - -{% block extra_head %} - -{% endblock %} diff --git a/kfet/templates/kfet/nav_item.html b/kfet/templates/kfet/nav_item.html index 8bc311b8..b5981266 100644 --- a/kfet/templates/kfet/nav_item.html +++ b/kfet/templates/kfet/nav_item.html @@ -4,6 +4,8 @@
  • {% if href %} + {% else %} + {% endif %} {% if href %} + {% else %} + {% endif %}
  • diff --git a/kfet/templates/kfet/order_create.html b/kfet/templates/kfet/order_create.html index 6ff9b824..d95cafe3 100644 --- a/kfet/templates/kfet/order_create.html +++ b/kfet/templates/kfet/order_create.html @@ -11,7 +11,7 @@
    {% csrf_token %}
    - +
    diff --git a/kfet/tests/test_forms.py b/kfet/tests/test_forms.py deleted file mode 100644 index 7f129a3f..00000000 --- a/kfet/tests/test_forms.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- - -from django.test import TestCase -from django.contrib.auth.models import User, Group - -from kfet.forms import UserGroupForm - - -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, - ) diff --git a/kfet/tests/test_models.py b/kfet/tests/test_models.py new file mode 100644 index 00000000..ea132acd --- /dev/null +++ b/kfet/tests/test_models.py @@ -0,0 +1,25 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from kfet.models import Account + +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') 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 index ec9d6ffc..41ed8b5c 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -1,26 +1,216 @@ +import json +from datetime import datetime, timedelta from decimal import Decimal -from unittest.mock import patch +from unittest import mock -from django.test import TestCase, Client -from django.contrib.auth.models import User +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 ..models import Account, OperationGroup, Checkout, Operation +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 AccountTests(TestCase): - """Account related views""" +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): - # A user and its account - self.user = User.objects.create_user(username="foobar", password="foo") - acc = Account.objects.create( - trigramme="FOO", cofprofile=self.user.profile - ) + super().setUp() + + user1_acc = self.accounts['user1'] + team_acc = self.accounts['team'] # Dummy operations and operation groups checkout = Checkout.objects.create( - created_by=acc, name="checkout", + created_by=team_acc, name="checkout", valid_from=timezone.now(), valid_to=timezone.now() + timezone.timedelta(days=365) ) @@ -30,7 +220,7 @@ class AccountTests(TestCase): ] OperationGroup.objects.bulk_create([ OperationGroup( - on_acc=acc, checkout=checkout, at=at, is_cof=False, + on_acc=user1_acc, checkout=checkout, at=at, is_cof=False, amount=amount ) for (at, amount) in opeg_data @@ -47,13 +237,1999 @@ class AccountTests(TestCase): amount=Decimal('3') ) - @patch('gestioncof.signals.messages') - def test_account_read(self, mock_messages): - """We can query the "Account - Read" page.""" + 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() - self.assertTrue(client.login( - username="foobar", - password="foo" - )) - resp = client.get("/k-fet/accounts/FOO") - self.assertEqual(200, resp.status_code) + 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() + self.checkout = Checkout.objects.create( + name='Checkout', + created_by=self.accounts['team'], + valid_from=self.now, + valid_to=self.now + timedelta(days=5), + ) + + 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='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), + ) + + +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..3ea428c3 --- /dev/null +++ b/kfet/tests/testcases.py @@ -0,0 +1,353 @@ +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: + # 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. + + """ + # 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 c3499b18..f39299a5 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -8,8 +8,8 @@ from kfet.decorators import teamkfet_required urlpatterns = [ - 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'), @@ -188,13 +188,9 @@ 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'), diff --git a/kfet/views.py b/kfet/views.py index 5e451c9c..1fe9ac22 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -12,34 +12,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 django_cas_ng.views import logout as cas_logout_view - 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, @@ -54,25 +50,9 @@ import heapq import statistics from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes, WeekScale - -@teamkfet_required -def login_genericteam(request): - # Check si besoin de déconnecter l'utilisateur de CAS - cas_logout = None - if request.user.profile.login_clipper: - # Récupèration de la vue de déconnexion de CAS - # Ici, car request sera modifié après - next_page = request.META.get('HTTP_REFERER', None) - cas_logout = cas_logout_view(request, next_page=next_page) - - # 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) - - messages.success(request, "Connecté en utilisateur partagé") - - return cas_logout or render(request, "kfet/login_genericteam.html") +from .auth.views import ( # noqa + account_group, login_generic, AccountGroupCreate, AccountGroupUpdate, +) def put_cleaned_data_in_dict(dict, form): @@ -268,10 +248,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 @@ -505,37 +485,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(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') - - class AccountNegativeList(ListView): queryset = ( AccountNegative.objects @@ -912,30 +861,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) @@ -1363,13 +1316,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 @@ -1452,6 +1405,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' @@ -1460,13 +1416,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 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 e35af45a..69bbcf4c 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -9,75 +9,63 @@ DBPASSWD="4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4" # Installation de paquets utiles apt-get update && apt-get upgrade -y -apt-get install -y python3-pip python3-dev python3-venv libmysqlclient-dev \ - libjpeg-dev git redis-server +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..1818a0cd 100644 --- a/provisioning/prepare_django.sh +++ b/provisioning/prepare_django.sh @@ -1,9 +1,6 @@ #!/bin/bash -# Doit être lancé par bootstrap.sh -source ~/venv/bin/activate 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 91b13db4..53e5061c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,16 @@ 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.7 django-djconfig==0.5.3 -django-grappelli==2.8.1 -django-recaptcha==1.0.5 +django-recaptcha==1.2.1 django-redis-cache==1.7.1 -mysqlclient==1.3.7 -Pillow==3.3.0 -six==1.10.0 -unicodecsv==0.14.1 -icalendar==3.10 +icalendar +psycopg2 +Pillow +six +unicodecsv django-bootstrap-form==3.2.1 asgiref==1.1.1 daphne==1.3.0 @@ -26,3 +25,6 @@ python-dateutil wagtail==1.10.* wagtailmenus==2.2.* wagtail-modeltranslation==0.6.0rc2 + +# Production tools +wheel 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())
    Article