diff --git a/.gitignore b/.gitignore index f12190af..ab791b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ venv/ /src media/ *.log +*.sqlite3 + +# PyCharm +.idea +.cache diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f2635b7b..19bcc736 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,25 +1,24 @@ services: - - mysql:latest + - postgres:latest - redis:latest variables: # GestioCOF settings - DJANGO_SETTINGS_MODULE: "cof.settings_dev" - DBNAME: "cof_gestion" - DBUSER: "cof_gestion" - DBPASSWD: "cof_password" - DBHOST: "mysql" + DJANGO_SETTINGS_MODULE: "cof.settings.prod" + DBHOST: "postgres" REDIS_HOST: "redis" + REDIS_PASSWD: "dummy" # Cached packages PYTHONPATH: "$CI_PROJECT_DIR/vendor/python" - # mysql service configuration - MYSQL_DATABASE: "$DBNAME" - MYSQL_USER: "$DBUSER" - MYSQL_PASSWORD: "$DBPASSWD" - MYSQL_ROOT_PASSWORD: "root_password" + # postgres service configuration + POSTGRES_PASSWORD: "4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4" + POSTGRES_USER: "cof_gestion" + POSTGRES_DB: "cof_gestion" + # psql password authentication + PGPASSWORD: $POSTGRES_PASSWORD cache: paths: @@ -29,13 +28,12 @@ cache: before_script: - mkdir -p vendor/{python,pip,apt} - - apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq mysql-client - - mysql --user=root --password="$MYSQL_ROOT_PASSWORD" --host="$DBHOST" - -e "GRANT ALL ON test_$DBNAME.* TO '$DBUSER'@'%'" + - apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client + - sed -E 's/^REDIS_HOST.*/REDIS_HOST = "redis"/' cof/settings/secret_example.py > cof/settings/secret.py + - sed -i.bak -E 's;^REDIS_PASSWD = .*$;REDIS_PASSWD = "";' cof/settings/secret.py # Remove the old test database if it has not been done yet - - mysql --user=root --password="$MYSQL_ROOT_PASSWORD" --host="$DBHOST" - -e "DROP DATABASE test_$DBNAME" || true - - pip install --cache-dir vendor/pip -t vendor/python -r requirements-devel.txt + - psql --username=$POSTGRES_USER --host=$DBHOST -c "DROP DATABASE IF EXISTS test_$POSTGRES_DB" + - pip install --upgrade --cache-dir vendor/pip -t vendor/python -r requirements.txt test: stage: test diff --git a/README.md b/README.md index 1a3d575e..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 accounts groups 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/__init__.py b/bda/__init__.py index e69de29b..8b137891 100644 --- a/bda/__init__.py +++ b/bda/__init__.py @@ -0,0 +1 @@ + diff --git a/bda/admin.py b/bda/admin.py index 0cc66d43..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 0228b4c0..73356038 100644 --- a/bda/models.py +++ b/bda/models.py @@ -6,11 +6,23 @@ from datetime import timedelta from custommail.shortcuts import send_mass_custom_mail from django.contrib.sites.models import Site +from django.core import mail from django.db import models +from django.db.models import Count from django.contrib.auth.models import User from django.conf import settings from django.utils import timezone, formats +from custommail.models import CustomMail + + +def get_generic_user(): + generic, _ = User.objects.get_or_create( + username="bda_generic", + defaults={"email": "bda@ens.fr", "first_name": "Bureau des arts"} + ) + return generic + class Tirage(models.Model): title = models.CharField("Titre", max_length=300) @@ -50,9 +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) @@ -62,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) @@ -96,32 +111,29 @@ class Spectacle(models.Model): Envoie un mail de rappel à toutes les personnes qui ont une place pour ce spectacle. """ - # On récupère la liste des participants - members = {} - for attr in Attribution.objects.filter(spectacle=self).all(): - member = attr.participant.user - if member.id in members: - members[member.id][1] = 2 - else: - members[member.id] = [member, 1] - # FIXME : faire quelque chose de ça, un utilisateur bda_generic ? - # # Pour le BdA - # members[0] = ['BdA', 1, 'bda@ens.fr'] - # members[-1] = ['BdA', 2, 'bda@ens.fr'] + # On récupère la liste des participants + le BdA + members = list( + User.objects + .filter(participant__attributions=self) + .annotate(nb_attr=Count("id")).order_by() + ) + bda_generic = get_generic_user() + bda_generic.nb_attr = 1 + members.append(bda_generic) # On écrit un mail personnalisé à chaque participant datatuple = [( 'bda-rappel', - {'member': member[0], 'nb_attr': member[1], 'show': self}, + {'member': member, "nb_attr": member.nb_attr, 'show': self}, settings.MAIL_DATA['rappels']['FROM'], - [member[0].email]) - for member in members.values() + [member.email]) + for member in members ] send_mass_custom_mail(datatuple) # On enregistre le fait que l'envoi a bien eu lieu self.rappel_sent = timezone.now() self.save() # On renvoie la liste des destinataires - return members.values() + return members @property def is_past(self): @@ -129,7 +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) @@ -143,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") @@ -154,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) @@ -170,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, @@ -198,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): @@ -208,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) @@ -306,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/mails-rappel.html b/bda/templates/bda/mails-rappel.html index 73625d1c..c10503b0 100644 --- a/bda/templates/bda/mails-rappel.html +++ b/bda/templates/bda/mails-rappel.html @@ -3,41 +3,46 @@ {% block realcontent %}

Mails de rappels

{% if sent %} -

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

- +

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

+ {% else %} -

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

- {% if show.rappel_sent %} -

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

- {% endif %} +

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

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

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

+ +
-
-

Forme des mails

Une seule place

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

Deux places

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

Ajouter une attribution

-
- - -
- - + + +
+ Page d'envoi manuel des mails de rappel +
+ - {% 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 %} - -{% endblock %} -{% block content_title %} - {% if title %} -

{{ title }}

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

{% trans app.name %}

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

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

- {% endfor %} -
-
-
-

{% trans 'Recent Actions' %}

-
-

{% trans 'My Actions' %}

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

{% trans 'None available' %}

- {% else %} -
    - {% for entry in admin_log %} -
  • - {% if entry.is_deletion %} - {{ entry.object_repr }} - {% else %} - {{ entry.object_repr }} - {% endif %} - {% filter capfirst %}{% trans entry.content_type.name %}{% endfilter %} -
  • - {% endfor %} -
- {% endif %} -
-
-
-
-{% endblock %} - diff --git a/gestioncof/templates/base.html b/gestioncof/templates/base.html index 6c570ae8..41880c61 100644 --- a/gestioncof/templates/base.html +++ b/gestioncof/templates/base.html @@ -8,13 +8,13 @@ - {# CSS #} + {# CSS #} - {# JS #} + {# JS #} {% block extra_head %}{% endblock %} diff --git a/gestioncof/templates/gestioncof/banner_update.html b/gestioncof/templates/gestioncof/banner_update.html new file mode 100644 index 00000000..b2432eae --- /dev/null +++ b/gestioncof/templates/gestioncof/banner_update.html @@ -0,0 +1,23 @@ +{% extends "base_title.html" %} +{% load bootstrap %} +{% load i18n %} + +{% block page_size %}col-sm-8{%endblock%} + +{% block realcontent %} +

{% trans "Global configuration" %}

+
+
+ {% csrf_token %} + + {% for field in form %} + {{ field | bootstrap }} + {% endfor %} + +
+
+ +
+
+{% endblock %} diff --git a/gestioncof/templates/gestioncof/base_header.html b/gestioncof/templates/gestioncof/base_header.html index a7e29eb7..e5f757a7 100644 --- a/gestioncof/templates/gestioncof/base_header.html +++ b/gestioncof/templates/gestioncof/base_header.html @@ -3,7 +3,7 @@ {% block content %}
+ +{% if config.gestion_banner %} + +{% endif %} + {% if messages %} {% for message in messages %}
diff --git a/gestioncof/templates/gestioncof/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 5f783a48..65c4ba5e 100644 --- a/gestioncof/templates/home.html +++ b/gestioncof/templates/home.html @@ -1,4 +1,5 @@ {% extends "gestioncof/base_header.html" %} +{% load wagtailcore_tags %} {% block homelink %} {% endblock %} @@ -13,7 +14,7 @@
@@ -23,7 +24,7 @@
@@ -55,7 +56,8 @@

K-Fêt

@@ -118,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 457a99c4..5dfee83f 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import unicodecsv import uuid from datetime import timedelta @@ -9,12 +7,20 @@ from custommail.shortcuts import send_custom_mail from django.shortcuts import redirect, get_object_or_404, render from django.http import Http404, HttpResponse, HttpResponseForbidden from django.contrib.auth.decorators import login_required -from django.contrib.auth.views import login as django_login_view +from django.contrib.auth.views import ( + login as django_login_view, logout as django_logout_view, +) from django.contrib.auth.models import User from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse_lazy +from django.views.generic import FormView from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ from django.contrib import messages -import django.utils.six as six + +from django_cas_ng.views import logout as cas_logout_view + +from utils.views.autocomplete import Select2QuerySetView from gestioncof.models import Survey, SurveyAnswer, SurveyQuestion, \ SurveyQuestionAnswer @@ -24,10 +30,11 @@ from gestioncof.models import EventCommentField, EventCommentValue, \ CalendarSubscription from gestioncof.models import CofProfile, Club from gestioncof.decorators import buro_required, cof_required -from gestioncof.forms import UserProfileForm, EventStatusFilterForm, \ - SurveyForm, SurveyStatusFilterForm, RegistrationUserForm, \ - RegistrationProfileForm, EventForm, CalendarForm, EventFormset, \ - RegistrationPassUserForm, ClubsForm +from gestioncof.forms import ( + UserProfileForm, EventStatusFilterForm, SurveyForm, SurveyStatusFilterForm, + RegistrationUserForm, RegistrationProfileForm, EventForm, CalendarForm, + EventFormset, RegistrationPassUserForm, ClubsForm, GestioncofConfigForm +) from bda.models import Tirage, Spectacle @@ -49,8 +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'] @@ -81,15 +88,21 @@ def login_ext(request): @login_required -def logout(request): - try: - profile = request.user.profile - except CofProfile.DoesNotExist: - profile, created = CofProfile.objects.get_or_create(user=request.user) - if profile.login_clipper: - return redirect("django_cas_ng.views.logout") +def logout(request, next_page=None): + if next_page is None: + next_page = request.GET.get('next', None) + + profile = getattr(request.user, 'profile', None) + + if profile and profile.login_clipper: + msg = _('Déconnexion de GestioCOF et CAS réussie. À bientôt {}.') + logout_view = cas_logout_view else: - return redirect("django.contrib.auth.views.logout") + msg = _('Déconnexion de GestioCOF réussie. À bientôt {}.') + logout_view = django_logout_view + + messages.success(request, msg.format(request.user.get_short_name())) + return logout_view(request, next_page=next_page) @login_required @@ -387,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() @@ -403,12 +415,8 @@ def registration_form2(request, login_clipper=None, username=None, def registration(request): if request.POST: request_dict = request.POST.copy() - # num ne peut pas être défini manuellement - if "num" in request_dict: - del request_dict["num"] member = None login_clipper = None - success = False # ----- # Remplissage des formulaires @@ -430,12 +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 @@ -445,7 +451,6 @@ def registration(request): member = user_form.save() profile, _ = CofProfile.objects.get_or_create(user=member) was_cof = profile.is_cof - request_dict["num"] = profile.num # Maintenant on remplit le formulaire de profil profile_form = RegistrationProfileForm(request_dict, instance=profile) @@ -499,16 +504,18 @@ def registration(request): for club in clubs_form.cleaned_data['clubs']: club.membres.add(member) club.save() - success = True - # Messages - if success: - msg = ("L'inscription de {:s} ({:s}) a été " - "enregistrée avec succès" - .format(member.get_full_name(), member.email)) - if member.profile.is_cof: - msg += "Il est désormais membre du COF n°{:d} !".format( - member.profile.num) - messages.success(request, msg, extra_tags='safe') + + # --- + # Success + # --- + + msg = ("L'inscription de {:s} ({:s}) a été " + "enregistrée avec succès." + .format(member.get_full_name(), member.email)) + if profile.is_cof: + msg += "\nIl est désormais membre du COF n°{:d} !".format( + member.profile.id) + messages.success(request, msg, extra_tags='safe') return render(request, "gestioncof/registration_post.html", {"user_form": user_form, "profile_form": profile_form, @@ -572,10 +579,10 @@ def export_members(request): writer = unicodecsv.writer(response) for profile in CofProfile.objects.filter(is_cof=True).all(): user = profile.user - bits = [profile.num, user.username, user.first_name, user.last_name, + bits = [user.id, user.username, user.first_name, user.last_name, user.email, profile.phone, profile.occupation, profile.departement, profile.type_cotiz] - writer.writerow([six.text_type(bit) for bit in bits]) + writer.writerow([str(bit) for bit in bits]) return response @@ -591,78 +598,80 @@ def csv_export_mega(filename, qs): comments = "---".join( [comment.content for comment in reg.comments.all()]) bits = [user.username, user.first_name, user.last_name, user.email, - profile.phone, profile.num, + profile.phone, user.id, profile.comments if profile.comments else "", comments] - writer.writerow([six.text_type(bit) for bit in bits]) + writer.writerow([str(bit) for bit in bits]) return response @buro_required def export_mega_remarksonly(request): - filename = 'remarques_mega_2016.csv' + filename = 'remarques_mega_2017.csv' response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename=' + filename writer = unicodecsv.writer(response) - event = Event.objects.get(title="Mega 2016") - commentfield = event.commentfields.get(name="Commentaires") + event = Event.objects.get(title="MEGA 2017") + commentfield = event.commentfields.get(name="Commentaire") for val in commentfield.values.all(): reg = val.registration user = reg.user profile = user.profile bits = [user.username, user.first_name, user.last_name, user.email, - profile.phone, profile.num, profile.comments, val.content] - writer.writerow([six.text_type(bit) for bit in bits]) + profile.phone, profile.id, profile.comments, val.content] + writer.writerow([str(bit) for bit in bits]) return response -@buro_required -def export_mega_bytype(request, type): - types = {"orga-actif": "Orga élève", - "orga-branleur": "Orga étudiant", - "conscrit-eleve": "Conscrit élève", - "conscrit-etudiant": "Conscrit étudiant"} - - if type not in types: - raise Http404 - - event = Event.objects.get(title="Mega 2016") - type_option = event.options.get(name="Type") - participant_type = type_option.choices.get(value=types[type]).id - qs = EventRegistration.objects.filter(event=event).filter( - options__id__exact=participant_type) - return csv_export_mega(type + '_mega_2016.csv', qs) +# @buro_required +# def export_mega_bytype(request, type): +# types = {"orga-actif": "Orga élève", +# "orga-branleur": "Orga étudiant", +# "conscrit-eleve": "Conscrit élève", +# "conscrit-etudiant": "Conscrit étudiant"} +# +# if type not in types: +# raise Http404 +# +# event = Event.objects.get(title="MEGA 2017") +# type_option = event.options.get(name="Type") +# participant_type = type_option.choices.get(value=types[type]).id +# qs = EventRegistration.objects.filter(event=event).filter( +# options__id__exact=participant_type) +# return csv_export_mega(type + '_mega_2017.csv', qs) @buro_required def export_mega_orgas(request): - event = Event.objects.get(title="Mega 2016") - type_option = event.options.get(name="Conscrit ou orga ?") - participant_type = type_option.choices.get(value="Vieux").id - qs = EventRegistration.objects.filter(event=event).exclude( - options__id=participant_type) - return csv_export_mega('orgas_mega_2016.csv', qs) + event = Event.objects.get(title="MEGA 2017") + type_option = event.options.get(name="Conscrit/Orga ?") + participant_type = type_option.choices.get(value="Orga").id + qs = EventRegistration.objects.filter(event=event).filter( + options__id=participant_type + ) + return csv_export_mega('orgas_mega_2017.csv', qs) @buro_required def export_mega_participants(request): - event = Event.objects.get(title="Mega 2016") - type_option = event.options.get(name="Conscrit ou orga ?") + event = Event.objects.get(title="MEGA 2017") + type_option = event.options.get(name="Conscrit/Orga ?") participant_type = type_option.choices.get(value="Conscrit").id qs = EventRegistration.objects.filter(event=event).filter( - options__id=participant_type) - return csv_export_mega('participants_mega_2016.csv', qs) + options__id=participant_type + ) + return csv_export_mega('participants_mega_2017.csv', qs) @buro_required def export_mega(request): - event = Event.objects.filter(title="Mega 2016") + event = Event.objects.filter(title="MEGA 2017") qs = EventRegistration.objects.filter(event=event) \ .order_by("user__username") - return csv_export_mega('all_mega_2016.csv', qs) + return csv_export_mega('all_mega_2017.csv', qs) @buro_required @@ -764,3 +773,33 @@ def calendar_ics(request, token): response = HttpResponse(content=vcal.to_ical()) response['Content-Type'] = "text/calendar" return response + + +class ConfigUpdate(FormView): + form_class = GestioncofConfigForm + template_name = "gestioncof/banner_update.html" + success_url = reverse_lazy("home") + + def dispatch(self, request, *args, **kwargs): + if request.user is None or not request.user.is_superuser: + raise Http404 + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + form.save() + return super().form_valid(form) + + +## +# Autocomplete views +# +# https://django-autocomplete-light.readthedocs.io/en/master/tutorial.html#create-an-autocomplete-view +## + + +class UserAutocomplete(Select2QuerySetView): + model = User + search_fields = ('username', 'first_name', 'last_name') + + +user_autocomplete = buro_required(UserAutocomplete.as_view()) diff --git a/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/__init__.py b/kfet/cms/__init__.py new file mode 100644 index 00000000..0f6cab45 --- /dev/null +++ b/kfet/cms/__init__.py @@ -0,0 +1 @@ +default_app_config = 'kfet.cms.apps.KFetCMSAppConfig' diff --git a/kfet/cms/apps.py b/kfet/cms/apps.py new file mode 100644 index 00000000..1db0e043 --- /dev/null +++ b/kfet/cms/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class KFetCMSAppConfig(AppConfig): + name = 'kfet.cms' + label = 'kfetcms' + verbose_name = 'CMS K-Fêt' + + def ready(self): + from . import hooks diff --git a/kfet/cms/context_processors.py b/kfet/cms/context_processors.py new file mode 100644 index 00000000..34f175d1 --- /dev/null +++ b/kfet/cms/context_processors.py @@ -0,0 +1,20 @@ +from kfet.models import Article + + +def get_articles(request=None): + articles = ( + Article.objects + .filter(is_sold=True, hidden=False) + .select_related('category') + .order_by('category__name', 'name') + ) + pressions, others = [], [] + for article in articles: + if article.category.name == 'Pression': + pressions.append(article) + else: + others.append(article) + return { + 'pressions': pressions, + 'articles': others, + } diff --git a/kfet/cms/fixtures/kfet_wagtail_17_05.json b/kfet/cms/fixtures/kfet_wagtail_17_05.json new file mode 100644 index 00000000..66ac7040 --- /dev/null +++ b/kfet/cms/fixtures/kfet_wagtail_17_05.json @@ -0,0 +1,1456 @@ +[ +{ + "fields": { + "root_page": 9, + "port": 8000, + "site_name": "Global", + "hostname": "localhost", + "is_default_site": true + }, + "pk": 2, + "model": "wagtailcore.site" +}, +{ + "fields": { + "live": true, + "show_in_menus": false, + "numchild": 1, + "title": "Root", + "slug": "root", + "depth": 1, + "expire_at": null, + "search_description": "", + "content_type": [ + "wagtailcore", + "page" + ], + "owner": null, + "expired": false, + "first_published_at": null, + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001", + "latest_revision_created_at": null, + "url_path": "/", + "locked": false, + "seo_title": "" + }, + "pk": 1, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 5, + "title": "Bienvenue en K-Fêt", + "slug": "k-fet", + "depth": 3, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "000100010001", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/", + "locked": false, + "seo_title": "Accueil" + }, + "pk": 3, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "Mode d'emploi", + "slug": "mode-demploi", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010001", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/mode-demploi/", + "locked": false, + "seo_title": "" + }, + "pk": 4, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "L'\u00e9quipe", + "slug": "equipe", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010002", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/equipe/", + "locked": false, + "seo_title": "" + }, + "pk": 5, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "La carte", + "slug": "carte", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010003", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/carte/", + "locked": false, + "seo_title": "" + }, + "pk": 6, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "Les soir\u00e9es", + "slug": "soirees", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010004", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/soirees/", + "locked": false, + "seo_title": "" + }, + "pk": 7, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 0, + "title": "Le flipper", + "slug": "flipper", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010005", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/flipper/", + "locked": false, + "seo_title": "" + }, + "pk": 8, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": true, + "numchild": 1, + "title": "Global", + "slug": "global", + "depth": 2, + "expire_at": null, + "search_description": "", + "content_type": [ + "wagtailcore", + "page" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "00010001", + "latest_revision_created_at": null, + "url_path": "/global/", + "locked": false, + "seo_title": "" + }, + "pk": 9, + "model": "wagtailcore.page" +}, +{ + "fields": { + "live": true, + "show_in_menus": false, + "numchild": 0, + "title": "Mentions l\u00e9gales", + "slug": "mentions-legales", + "depth": 4, + "expire_at": null, + "search_description": "", + "content_type": [ + "kfetcms", + "kfetpage" + ], + "owner": [ + "kfet_genericteam" + ], + "expired": false, + "first_published_at": "2017-05-28T04:20:00.000Z", + "has_unpublished_changes": false, + "go_live_at": null, + "path": "0001000100010006", + "latest_revision_created_at": null, + "url_path": "/global/k-fet/mentions-legales/", + "locked": false, + "seo_title": "" + }, + "pk": 10, + "model": "wagtailcore.page" +}, +{ + "fields": { + "name": "Root", + "numchild": 1, + "path": "0001", + "depth": 1 + }, + "pk": 1, + "model": "wagtailcore.collection" +}, +{ + "fields": { + "name": "K-F\u00eat", + "numchild": 0, + "path": "00010001", + "depth": 2 + }, + "pk": 2, + "model": "wagtailcore.collection" +}, +{ + "pk": 1, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Hugo", + "nick_name": "", + "photo": 3, + "last_name": "Manet" + } +}, +{ + "pk": 2, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Lisa", + "nick_name": "", + "photo": 4, + "last_name": "Gourdon" + } +}, +{ + "pk": 3, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Pierre", + "nick_name": "", + "photo": 5, + "last_name": "Quesselaire" + } +}, +{ + "pk": 4, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Thibault", + "nick_name": "", + "photo": 6, + "last_name": "Scoquard" + } +}, +{ + "pk": 5, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Arnaud", + "nick_name": "", + "photo": 7, + "last_name": "Fanthomme" + } +}, +{ + "pk": 6, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Vincent", + "nick_name": "", + "photo": 8, + "last_name": "Balerdi" + } +}, +{ + "pk": 7, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Nathana\u00ebl", + "nick_name": "", + "photo": 9, + "last_name": "Willaime" + } +}, +{ + "pk": 8, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "\u00c9lisabeth", + "nick_name": "", + "photo": 10, + "last_name": "Miller" + } +}, +{ + "pk": 9, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Arthur", + "nick_name": "B2O", + "photo": 11, + "last_name": "Lesage" + } +}, +{ + "pk": 10, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Sarah", + "nick_name": "", + "photo": 12, + "last_name": "Asset" + } +}, +{ + "pk": 11, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Alexandre", + "nick_name": "", + "photo": 13, + "last_name": "Legrand" + } +}, +{ + "pk": 12, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "\u00c9tienne", + "nick_name": "", + "photo": 14, + "last_name": "Baudel" + } +}, +{ + "pk": 13, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Marine", + "nick_name": "", + "photo": 15, + "last_name": "Snape" + } +}, +{ + "pk": 14, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Anatole", + "nick_name": "", + "photo": 16, + "last_name": "Gosset" + } +}, +{ + "pk": 15, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Jacko", + "nick_name": "", + "photo": 17, + "last_name": "Rastikian" + } +}, +{ + "pk": 16, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Alexandre", + "nick_name": "", + "photo": 18, + "last_name": "Jannaud" + } +}, +{ + "pk": 17, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Aur\u00e9lien", + "nick_name": "", + "photo": 19, + "last_name": "Delobelle" + } +}, +{ + "pk": 18, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Sylvain", + "nick_name": "", + "photo": 20, + "last_name": "Douteau" + } +}, +{ + "pk": 19, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Rapha\u00ebl", + "nick_name": "", + "photo": 21, + "last_name": "Lescanne" + } +}, +{ + "pk": 20, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Romain", + "nick_name": "", + "photo": 22, + "last_name": "Gourvil" + } +}, +{ + "pk": 21, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Marie", + "nick_name": "", + "photo": 23, + "last_name": "Labeye" + } +}, +{ + "pk": 22, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Oscar", + "nick_name": "", + "photo": 24, + "last_name": "Blumberg" + } +}, +{ + "pk": 23, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Za\u00efd", + "nick_name": "", + "photo": 25, + "last_name": "Allybokus" + } +}, +{ + "pk": 24, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Damien", + "nick_name": "", + "photo": 26, + "last_name": "Garreau" + } +}, +{ + "pk": 25, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Andr\u00e9a", + "nick_name": "", + "photo": 27, + "last_name": "Londonez-Lopez" + } +}, +{ + "pk": 26, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Tristan", + "nick_name": "", + "photo": 28, + "last_name": "Roussel" + } +}, +{ + "pk": 27, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Guillaume", + "nick_name": "", + "photo": 29, + "last_name": "Vernade" + } +}, +{ + "pk": 28, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Lucas", + "nick_name": "", + "photo": 30, + "last_name": "Mercier" + } +}, +{ + "pk": 29, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Fran\u00e7ois", + "nick_name": "M\u00e9talleux", + "photo": 31, + "last_name": "Maillot" + } +}, +{ + "pk": 30, + "model": "kfetcms.memberteam", + "fields": { + "first_name": "Fabrice", + "nick_name": "", + "photo": 32, + "last_name": "Catoire" + } +}, +{ + "pk": 3, + "model": "kfetcms.kfetpage", + "fields": { + "main_size": "", + "content": "[{\"value\": \"

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

\\n\\n

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

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

Mais on n'y fait que boire et manger ?

\\n\\n

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

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

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

\\n\\n

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

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

Et je peux y faire ce que je veux ?

\\n\\n

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

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

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

\\n\\n

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

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

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

\\n\\n

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

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

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

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

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

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

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

Que puis-je faire pour vous aider un peu ?

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

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

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

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

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

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

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

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

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

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

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

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

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

Et amener ma propre bouteille ?

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

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

Je peux passer ma musique ?

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

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

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

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

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

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

D'autres remarques ?

Des tonnes, en voici quelques unes :

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

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

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

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

Le chef

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

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

Les K-F\\u00eat Wo-Men

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

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

Les Vieux-illes

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

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

Les K-F\\u00eat boys et girls

\\n\\n

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

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

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

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

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

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

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

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

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

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

Quand puis-je organiser une soir\\u00e9e ?

\\n\\n

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

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

Quelles d\\u00e9marches dois-je effectuer ?

\\n

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

\\n

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

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

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

\\n

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

\\n

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

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

Je pourrai passer ma musique ?

\\n\\n

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

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

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

\\n

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

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

Et pendant la soir\\u00e9e ?

\\n

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

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

Apr\\u00e8s c'est bon ?

\\n

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

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

Une derni\\u00e8re remarque ?

\\n\\n

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

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

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

\\n

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

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

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

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

Et le baby-foot

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

LE flipper

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

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

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

Le baby-foot

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

La d\\u00e9funte fun machine

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

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

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

Responsable de la publication

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

    45 rue d'Ulm

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

Informations prestataires

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

{{ title }}

+
    + {% for article in articles %} +
  • +
    + {{ article.name }} + {{ article.price_ukf }} UKF +
  • + {% endfor %} +
+
diff --git a/kfet/cms/templates/kfetcms/block_teamgroup.html b/kfet/cms/templates/kfetcms/block_teamgroup.html new file mode 100644 index 00000000..fab43d68 --- /dev/null +++ b/kfet/cms/templates/kfetcms/block_teamgroup.html @@ -0,0 +1,66 @@ +{% load wagtailcore_tags wagtailimages_tags %} + + +{% with groupteam=value len=value.members|length %} + +
+ + {% if len == 2 %} +
+ {% endif %} + + {% for member in groupteam.members %} +
+
+ {% image member.photo max-200x500 %} +
+ {{ member.get_full_name }} +
+ {% if member.nick_name %} + alias {{ member.nick_name }} + {% endif %} +
+
+
+ {% endfor %} + + {% if groupteam.show_only != None and len > groupteam.show_only %} +
+ +
+ {% endif %} + +
+ +{% endwith %} + + diff --git a/kfet/consumers.py b/kfet/consumers.py index ee096368..0f447d2d 100644 --- a/kfet/consumers.py +++ b/kfet/consumers.py @@ -1,39 +1,6 @@ # -*- coding: utf-8 -*- -from django.core.serializers.json import json, DjangoJSONEncoder - -from channels.generic.websockets import JsonWebsocketConsumer - - -class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer): - """Custom Json Websocket Consumer. - - Encode to JSON with DjangoJSONEncoder. - - """ - - @classmethod - def encode_json(cls, content): - return json.dumps(content, cls=DjangoJSONEncoder) - - -class PermConsumerMixin(object): - """Add support to check permissions on Consumers. - - Attributes: - perms_connect (list): Required permissions to connect to this - consumer. - - """ - http_user = True # Enable message.user - perms_connect = [] - - def connect(self, message, **kwargs): - """Check permissions on connection.""" - if message.user.has_perms(self.perms_connect): - super().connect(message, **kwargs) - else: - self.close() +from .utils import DjangoJsonWebsocketConsumer, PermConsumerMixin class KPsul(PermConsumerMixin, DjangoJsonWebsocketConsumer): diff --git a/kfet/context_processors.py b/kfet/context_processors.py index 4c7b4fe4..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 09c3a4d0..963e4254 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -5,9 +5,7 @@ from decimal import Decimal from django import forms from django.core.exceptions import ValidationError -from django.core.validators import MinLengthValidator -from django.contrib.auth.models import User, Group, Permission -from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import User from django.forms import modelformset_factory from django.utils import timezone @@ -19,24 +17,25 @@ from kfet.models import ( TransferGroup, Supplier) from gestioncof.models import CofProfile +from .auth.forms import UserGroupForm # noqa + # ----- # Widgets # ----- class DateTimeWidget(forms.DateTimeInput): - def __init__(self, attrs = None): - super(DateTimeWidget, self).__init__(attrs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.attrs['format'] = '%Y-%m-%d %H:%M' + class Media: css = { - 'all': ('kfet/css/bootstrap-datetimepicker.min.css',) - } - js = ( - 'kfet/js/moment.js', - 'kfet/js/moment-fr.js', - 'kfet/js/bootstrap-datetimepicker.min.js', - ) + 'all': ('kfet/css/bootstrap-datetimepicker.min.css',) + } + js = ('kfet/js/bootstrap-datetimepicker.min.js',) + + # ----- # Account forms # ----- @@ -111,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'] @@ -135,35 +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 GroupForm(forms.ModelForm): - permissions = forms.ModelMultipleChoiceField( - queryset= Permission.objects.filter(content_type__in= - ContentType.objects.filter(app_label='kfet'))) - - def clean_name(self): - name = self.cleaned_data['name'] - return 'K-Fêt %s' % name - - class Meta: - model = Group - fields = ['name', 'permissions'] - class AccountNegativeForm(forms.ModelForm): class Meta: model = AccountNegative @@ -445,8 +410,11 @@ class KFetConfigForm(ConfigForm): class FilterHistoryForm(forms.Form): - checkouts = forms.ModelMultipleChoiceField(queryset = Checkout.objects.all()) - accounts = forms.ModelMultipleChoiceField(queryset = Account.objects.all()) + checkouts = forms.ModelMultipleChoiceField(queryset=Checkout.objects.all()) + accounts = forms.ModelMultipleChoiceField(queryset=Account.objects.all()) + from_date = forms.DateTimeField(widget=DateTimeWidget) + to_date = forms.DateTimeField(widget=DateTimeWidget) + # ----- # Transfer forms @@ -525,11 +493,7 @@ class OrderArticleForm(forms.Form): self.category = kwargs['initial']['category'] self.category_name = kwargs['initial']['category__name'] self.box_capacity = kwargs['initial']['box_capacity'] - self.v_s1 = kwargs['initial']['v_s1'] - self.v_s2 = kwargs['initial']['v_s2'] - self.v_s3 = kwargs['initial']['v_s3'] - self.v_s4 = kwargs['initial']['v_s4'] - self.v_s5 = kwargs['initial']['v_s5'] + self.v_all = kwargs['initial']['v_all'] self.v_moy = kwargs['initial']['v_moy'] self.v_et = kwargs['initial']['v_et'] self.v_prev = kwargs['initial']['v_prev'] diff --git a/kfet/management/commands/loadkfetdevdata.py b/kfet/management/commands/loadkfetdevdata.py index 7f2ec9a3..6dd25f29 100644 --- a/kfet/management/commands/loadkfetdevdata.py +++ b/kfet/management/commands/loadkfetdevdata.py @@ -147,3 +147,9 @@ class Command(MyBaseCommand): # --- call_command('createopes', '100', '7', '--transfers=20') + + # --- + # Wagtail CMS + # --- + + call_command('kfet_loadwagtail') diff --git a/kfet/middleware.py b/kfet/middleware.py deleted file mode 100644 index 9502d393..00000000 --- a/kfet/middleware.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- - -from django.contrib.auth.models import User - -from kfet.backends import KFetBackend - - -class KFetAuthenticationMiddleware(object): - """Authenticate another user for this request if KFetBackend succeeds. - - By the way, if a user is authenticated, we refresh its from db to add - values from CofProfile and Account of this user. - - """ - def process_request(self, request): - if request.user.is_authenticated(): - # avoid multiple db accesses in views and templates - user_pk = request.user.pk - request.user = ( - User.objects - .select_related('profile__account_kfet') - .get(pk=user_pk) - ) - - kfet_backend = KFetBackend() - temp_request_user = kfet_backend.authenticate(request) - if temp_request_user: - request.real_user = request.user - request.user = temp_request_user diff --git a/kfet/migrations/0054_update_promos.py b/kfet/migrations/0054_update_promos.py new file mode 100644 index 00000000..2691e903 --- /dev/null +++ b/kfet/migrations/0054_update_promos.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0053_created_at'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='promo', + field=models.IntegerField(blank=True, choices=[(1980, 1980), (1981, 1981), (1982, 1982), (1983, 1983), (1984, 1984), (1985, 1985), (1986, 1986), (1987, 1987), (1988, 1988), (1989, 1989), (1990, 1990), (1991, 1991), (1992, 1992), (1993, 1993), (1994, 1994), (1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017)], default=2017, null=True), + ), + ] diff --git a/kfet/migrations/0055_move_permissions.py b/kfet/migrations/0055_move_permissions.py new file mode 100644 index 00000000..a418124c --- /dev/null +++ b/kfet/migrations/0055_move_permissions.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +def forwards_perms(apps, schema_editor): + """Safely delete content type for old kfet.GlobalPermissions model. + + Any permissions (except defaults) linked to this content type are updated + to link at its new content type. + Then, delete the content type. This will delete the three defaults + permissions which are assumed unused. + + """ + ContentType = apps.get_model('contenttypes', 'contenttype') + try: + ctype_global = ContentType.objects.get( + app_label="kfet", model="globalpermissions", + ) + except ContentType.DoesNotExist: + # We are not migrating from existing data, nothing to do. + return + + perms = { + 'account': ( + 'is_team', 'manage_perms', 'manage_addcosts', + 'edit_balance_account', 'change_account_password', + 'special_add_account', + ), + 'accountnegative': ('view_negs',), + 'inventory': ('order_to_inventory',), + 'operation': ( + 'perform_deposit', 'perform_negative_operations', + 'override_frozen_protection', 'cancel_old_operations', + 'perform_commented_operations', + ), + } + + Permission = apps.get_model('auth', 'permission') + global_perms = Permission.objects.filter(content_type=ctype_global) + + for modelname, codenames in perms.items(): + model = apps.get_model('kfet', modelname) + ctype = ContentType.objects.get_for_model(model) + ( + global_perms + .filter(codename__in=codenames) + .update(content_type=ctype) + ) + + ctype_global.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0054_delete_settings'), + ('contenttypes', '__latest__'), + ('auth', '__latest__'), + ] + + operations = [ + migrations.AlterModelOptions( + name='account', + options={'permissions': (('is_team', 'Is part of the team'), ('manage_perms', 'Gérer les permissions K-Fêt'), ('manage_addcosts', 'Gérer les majorations'), ('edit_balance_account', "Modifier la balance d'un compte"), ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', 'Créer un compte avec une balance initiale'))}, + ), + migrations.AlterModelOptions( + name='accountnegative', + options={'permissions': (('view_negs', 'Voir la liste des négatifs'),)}, + ), + migrations.AlterModelOptions( + name='inventory', + options={'ordering': ['-at'], 'permissions': (('order_to_inventory', "Générer un inventaire à partir d'une commande"),)}, + ), + migrations.AlterModelOptions( + name='operation', + options={'permissions': (('perform_deposit', 'Effectuer une charge'), ('perform_negative_operations', 'Enregistrer des commandes en négatif'), ('override_frozen_protection', "Forcer le gel d'un compte"), ('cancel_old_operations', 'Annuler des commandes non récentes'), ('perform_commented_operations', 'Enregistrer des commandes avec commentaires'))}, + ), + migrations.RunPython(forwards_perms), + ] diff --git a/kfet/migrations/0056_change_account_meta.py b/kfet/migrations/0056_change_account_meta.py new file mode 100644 index 00000000..3992bf3c --- /dev/null +++ b/kfet/migrations/0056_change_account_meta.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0055_move_permissions'), + ] + + operations = [ + migrations.AlterModelOptions( + name='account', + options={'permissions': (('is_team', 'Is part of the team'), ('manage_perms', 'Gérer les permissions K-Fêt'), ('manage_addcosts', 'Gérer les majorations'), ('edit_balance_account', "Modifier la balance d'un compte"), ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', 'Créer un compte avec une balance initiale'), ('can_force_close', 'Fermer manuellement la K-Fêt'))}, + ), + ] diff --git a/kfet/migrations/0057_merge.py b/kfet/migrations/0057_merge.py new file mode 100644 index 00000000..48f63399 --- /dev/null +++ b/kfet/migrations/0057_merge.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0056_change_account_meta'), + ('kfet', '0054_update_promos'), + ] + + operations = [ + ] diff --git a/kfet/migrations/0058_delete_genericteamtoken.py b/kfet/migrations/0058_delete_genericteamtoken.py new file mode 100644 index 00000000..ea8b55cd --- /dev/null +++ b/kfet/migrations/0058_delete_genericteamtoken.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0057_merge'), + ] + + operations = [ + migrations.DeleteModel( + name='GenericTeamToken', + ), + ] diff --git a/kfet/migrations/0059_create_generic.py b/kfet/migrations/0059_create_generic.py new file mode 100644 index 00000000..4f04770c --- /dev/null +++ b/kfet/migrations/0059_create_generic.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +from kfet.auth import KFET_GENERIC_TRIGRAMME, KFET_GENERIC_USERNAME + + +def setup_kfet_generic_user(apps, schema_editor): + """ + Setup models instances for the kfet generic account. + + Username and trigramme are retrieved from kfet.auth.__init__ module. + Other data are registered here. + + See also setup_kfet_generic_user from kfet.auth.utils module. + """ + User = apps.get_model('auth', 'User') + CofProfile = apps.get_model('gestioncof', 'CofProfile') + Account = apps.get_model('kfet', 'Account') + + user, _ = User.objects.update_or_create( + username=KFET_GENERIC_USERNAME, + defaults={ + 'first_name': 'Compte générique K-Fêt', + }, + ) + profile, _ = CofProfile.objects.update_or_create(user=user) + account, _ = Account.objects.update_or_create( + cofprofile=profile, + defaults={ + 'trigramme': KFET_GENERIC_TRIGRAMME, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0058_delete_genericteamtoken'), + ] + + operations = [ + migrations.RunPython(setup_kfet_generic_user), + ] diff --git a/kfet/migrations/0060_amend_supplier.py b/kfet/migrations/0060_amend_supplier.py new file mode 100644 index 00000000..4eb569f8 --- /dev/null +++ b/kfet/migrations/0060_amend_supplier.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0059_create_generic'), + ] + + operations = [ + migrations.AlterField( + model_name='supplier', + name='address', + field=models.TextField(verbose_name='adresse', blank=True), + ), + migrations.AlterField( + model_name='supplier', + name='articles', + field=models.ManyToManyField(verbose_name='articles vendus', through='kfet.SupplierArticle', related_name='suppliers', to='kfet.Article'), + ), + migrations.AlterField( + model_name='supplier', + name='comment', + field=models.TextField(verbose_name='commentaire', blank=True), + ), + migrations.AlterField( + model_name='supplier', + name='email', + field=models.EmailField(max_length=254, verbose_name='adresse mail', blank=True), + ), + migrations.AlterField( + model_name='supplier', + name='phone', + field=models.CharField(max_length=20, verbose_name='téléphone', blank=True), + ), + ] diff --git a/kfet/migrations/0061_add_perms_config.py b/kfet/migrations/0061_add_perms_config.py new file mode 100644 index 00000000..01bdf51d --- /dev/null +++ b/kfet/migrations/0061_add_perms_config.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0060_amend_supplier'), + ] + + operations = [ + migrations.AlterModelOptions( + name='account', + options={'permissions': (('is_team', 'Is part of the team'), ('manage_perms', 'Gérer les permissions K-Fêt'), ('manage_addcosts', 'Gérer les majorations'), ('edit_balance_account', "Modifier la balance d'un compte"), ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', 'Créer un compte avec une balance initiale'), ('can_force_close', 'Fermer manuellement la K-Fêt'), ('see_config', 'Voir la configuration K-Fêt'), ('change_config', 'Modifier la configuration K-Fêt'))}, + ), + ] diff --git a/kfet/migrations/0062_delete_globalpermissions.py b/kfet/migrations/0062_delete_globalpermissions.py new file mode 100644 index 00000000..ee245412 --- /dev/null +++ b/kfet/migrations/0062_delete_globalpermissions.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('kfet', '0061_add_perms_config'), + ] + + operations = [ + migrations.DeleteModel( + name='GlobalPermissions', + ), + ] diff --git a/kfet/models.py b/kfet/models.py index cc35df18..deee76eb 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -1,20 +1,24 @@ # -*- 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 kfet.config import kfet_config +from .auth import KFET_GENERIC_TRIGRAMME +from .auth.models import GenericTeamToken # noqa + +from .config import kfet_config +from .utils import to_ukf def choices_length(choices): return reduce(lambda m, choice: max(m, len(choice[0])), choices, 0) @@ -32,6 +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() @@ -63,10 +84,25 @@ class Account(models.Model): unique = True, blank = True, null = True, default = None) + class Meta: + permissions = ( + ('is_team', 'Is part of the team'), + ('manage_perms', 'Gérer les permissions K-Fêt'), + ('manage_addcosts', 'Gérer les majorations'), + ('edit_balance_account', "Modifier la balance d'un compte"), + ('change_account_password', + "Modifier le mot de passe d'une personne de l'équipe"), + ('special_add_account', + "Créer un compte avec une balance initiale"), + ('can_force_close', "Fermer manuellement la K-Fêt"), + ('see_config', "Voir la configuration K-Fêt"), + ('change_config', "Modifier la configuration K-Fêt"), + ) + def __str__(self): return '%s (%s)' % (self.trigramme, self.name) - # Propriétés pour accéder aux attributs de user et cofprofile et user + # Propriétés pour accéder aux attributs de cofprofile et user @property def user(self): return self.cofprofile.user @@ -90,6 +126,10 @@ class Account(models.Model): return self.cofprofile.is_cof # Propriétés supplémentaires + @property + def balance_ukf(self): + return to_ukf(self.balance, is_cof=self.is_cof) + @property def real_balance(self): if hasattr(self, 'negative') and self.negative.balance_offset: @@ -108,6 +148,14 @@ class Account(models.Model): def need_comment(self): return self.trigramme == '#13' + @property + def readable(self): + return self.trigramme != 'GNR' + + @property + def is_team(self): + return self.has_perm('kfet.is_team') + @staticmethod def is_validandfree(trigramme): data = { 'is_valid' : False, 'is_free' : False } @@ -210,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 @@ -284,6 +331,15 @@ class AccountNegative(models.Model): ) comment = models.CharField("commentaire", max_length=255, blank=True) + class Meta: + permissions = ( + ('view_negs', 'Voir la liste des négatifs'), + ) + + @property + def until_default(self): + return self.start + kfet_config.overdraft_duration + class Checkout(models.Model): created_by = models.ForeignKey( @@ -436,6 +492,10 @@ class Article(models.Model): def get_absolute_url(self): return reverse('kfet.article.read', kwargs={'pk': self.pk}) + def price_ukf(self): + return to_ukf(self.price) + + class ArticleRule(models.Model): article_on = models.OneToOneField( Article, on_delete = models.PROTECT, @@ -462,6 +522,10 @@ class Inventory(models.Model): class Meta: ordering = ['-at'] + permissions = ( + ('order_to_inventory', "Générer un inventaire à partir d'une commande"), + ) + class InventoryArticle(models.Model): inventory = models.ForeignKey( @@ -479,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) @@ -638,6 +705,17 @@ class Operation(models.Model): max_digits=6, decimal_places=2, blank=True, null=True, default=None) + class Meta: + permissions = ( + ('perform_deposit', 'Effectuer une charge'), + ('perform_negative_operations', + 'Enregistrer des commandes en négatif'), + ('override_frozen_protection', "Forcer le gel d'un compte"), + ('cancel_old_operations', 'Annuler des commandes non récentes'), + ('perform_commented_operations', + 'Enregistrer des commandes avec commentaires'), + ) + @property def is_checkout(self): return (self.type == Operation.DEPOSIT or @@ -656,28 +734,3 @@ class Operation(models.Model): return templates[self.type].format(nb=self.article_nb, article=self.article, amount=self.amount) - - -class GlobalPermissions(models.Model): - class Meta: - managed = False - permissions = ( - ('is_team', 'Is part of the team'), - ('perform_deposit', 'Effectuer une charge'), - ('perform_negative_operations', - 'Enregistrer des commandes en négatif'), - ('override_frozen_protection', "Forcer le gel d'un compte"), - ('cancel_old_operations', 'Annuler des commandes non récentes'), - ('manage_perms', 'Gérer les permissions K-Fêt'), - ('manage_addcosts', 'Gérer les majorations'), - ('perform_commented_operations', 'Enregistrer des commandes avec commentaires'), - ('view_negs', 'Voir la liste des négatifs'), - ('order_to_inventory', "Générer un inventaire à partir d'une commande"), - ('edit_balance_account', "Modifier la balance d'un compte"), - ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), - ('special_add_account', "Créer un compte avec une balance initiale") - ) - - -class GenericTeamToken(models.Model): - token = models.CharField(max_length = 50, unique = True) diff --git a/kfet/open/__init__.py b/kfet/open/__init__.py new file mode 100644 index 00000000..fb88af65 --- /dev/null +++ b/kfet/open/__init__.py @@ -0,0 +1 @@ +from .open import OpenKfet, kfet_open # noqa diff --git a/kfet/open/consumers.py b/kfet/open/consumers.py new file mode 100644 index 00000000..b28a4664 --- /dev/null +++ b/kfet/open/consumers.py @@ -0,0 +1,25 @@ +from ..decorators import kfet_is_team +from ..utils import DjangoJsonWebsocketConsumer, PermConsumerMixin + +from .open import kfet_open + + +class OpenKfetConsumer(PermConsumerMixin, DjangoJsonWebsocketConsumer): + """Consumer for K-Fêt Open. + + WS groups: + kfet.open.base: Only carries the values visible for all users. + kfet.open.team: Carries all values (raw status...). + + """ + + def connection_groups(self, user, **kwargs): + """Select which group the user should be connected.""" + if kfet_is_team(user): + return ['kfet.open.team'] + return ['kfet.open.base'] + + def connect(self, message, *args, **kwargs): + """Send current status on connect.""" + super().connect(message, *args, **kwargs) + self.send(kfet_open.export(message.user)) diff --git a/kfet/open/open.py b/kfet/open/open.py new file mode 100644 index 00000000..82d6217a --- /dev/null +++ b/kfet/open/open.py @@ -0,0 +1,109 @@ +from datetime import timedelta + +from django.utils import timezone + +from ..decorators import kfet_is_team +from ..utils import CachedMixin + + +class OpenKfet(CachedMixin, object): + """Manage "open" status of a place. + + Stores raw data (e.g. sent by raspberry), and user-set values + (as force_close). + Setting differents `cache_prefix` allows different places management. + Current state persists through cache. + + """ + # status is unknown after this duration + time_unknown = timedelta(minutes=15) + + # status + OPENED = 'opened' + CLOSED = 'closed' + UNKNOWN = 'unknown' + # admin status + FAKE_CLOSED = 'fake_closed' + + # cached attributes config + cached = { + '_raw_open': False, + '_last_update': None, + 'force_close': False, + } + cache_prefix = 'kfetopen' + + @property + def raw_open(self): + """Defined as property to update `last_update` on `raw_open` update.""" + return self._raw_open + + @raw_open.setter + def raw_open(self, value): + self._last_update = timezone.now() + self._raw_open = value + + @property + def last_update(self): + """Prevent `last_update` to be set.""" + return self._last_update + + @property + def is_open(self): + """Take into account force_close.""" + return False if self.force_close else self.raw_open + + def status(self): + if (self.last_update is None or + timezone.now() - self.last_update >= self.time_unknown): + return self.UNKNOWN + return self.OPENED if self.is_open else self.CLOSED + + def admin_status(self, status=None): + if status is None: + status = self.status() + if status == self.CLOSED and self.raw_open: + return self.FAKE_CLOSED + return status + + def _export(self): + """Export internal state. + + Used by WS initialization and updates. + + Returns: + (tuple): (base, team) + - team for team users. + - base for others. + + """ + status = self.status() + base = { + 'status': status, + } + restrict = { + 'admin_status': self.admin_status(status), + 'force_close': self.force_close, + } + return base, dict(base, **restrict) + + def export(self, user): + """Export internal state for a given user. + + Returns: + (dict): Internal state. Only variables visible for the user are + exported, according to its permissions. + + """ + base, team = self._export() + return team if kfet_is_team(user) else base + + def send_ws(self): + """Send internal state to websocket channels.""" + from .consumers import OpenKfetConsumer + base, team = self._export() + OpenKfetConsumer.group_send('kfet.open.base', base) + OpenKfetConsumer.group_send('kfet.open.team', team) + + +kfet_open = OpenKfet() diff --git a/kfet/open/routing.py b/kfet/open/routing.py new file mode 100644 index 00000000..681bfab2 --- /dev/null +++ b/kfet/open/routing.py @@ -0,0 +1,8 @@ +from channels.routing import route_class + +from . import consumers + + +routing = [ + route_class(consumers.OpenKfetConsumer) +] diff --git a/kfet/open/static/kfetopen/kfet-open.css b/kfet/open/static/kfetopen/kfet-open.css new file mode 100644 index 00000000..a7068626 --- /dev/null +++ b/kfet/open/static/kfetopen/kfet-open.css @@ -0,0 +1,69 @@ +.kfetopen-st-opened .bullet { background: #73C252; } +.kfetopen-st-closed .bullet { background: #B42B26; } +.kfetopen-st-unknown .bullet { background: #D4BE4C; } +.kfetopen-st-fake_closed .bullet { + background: repeating-linear-gradient( + 45deg, + #73C252, #73C252 5px, #B42B26 5px, #B42B26 10px + ); +} + +.kfetopen { + float: left; +} + +.kfetopen .base { + height: 50px; + max-width: 16px; + + margin-left: 5px; + margin-right: 5px; + + display: flex; + flex-wrap: wrap; + align-content: center; + align-items: center; + justify-content: center; +} + +.kfetopen .details { + margin: 0; + padding: 10px !important; + min-width: 200px; + font-family: "Roboto Slab"; + font-size: 16px; + color: black; +} + +.kfetopen .bullet { + width: 10px; + height: 10px; + border-radius: 50%; + transition: background 0.15s; + margin: 3px; +} + +.kfetopen .warning { + display: none; +} + +@media (min-width: 576px) { + .kfetopen .base { + max-width: none; + margin-left: 15px; + margin-right: 15px; + } + + .kfetopen .warning { + margin-left: 15px; + } +} + +.kfetopen .status-text { + text-transform: uppercase; +} + +.kfetopen .force-close-btn { + width: 100%; + margin-top: 5px; +} diff --git a/kfet/open/static/kfetopen/kfet-open.js b/kfet/open/static/kfetopen/kfet-open.js new file mode 100644 index 00000000..74f18d8a --- /dev/null +++ b/kfet/open/static/kfetopen/kfet-open.js @@ -0,0 +1,113 @@ +var OpenWS = new KfetWebsocket({ + relative_url: "open/" +}); + +var OpenKfet = function(force_close_url, admin) { + this.force_close_url = force_close_url; + this.admin = admin; + + this.status = this.UNKNOWN; + this.dom = { + status_text: $('.kfetopen .status-text'), + force_close_btn: $('.kfetopen .force-close-btn'), + warning: $('.kfetopen .warning') + }, + + this.dom.force_close_btn.click( () => this.toggle_force_close() ); + setInterval( () => this.refresh(), this.refresh_interval * 1000); + OpenWS.add_handler( data => this.refresh(data) ); + +}; + +OpenKfet.prototype = { + // Status is unknown after . minutes without update. + time_unknown: 15, + // Maximum interval (seconds) between two UI refresh. + refresh_interval: 20, + + // Prefix for classes describing place status. + class_prefix: 'kfetopen-st-', + // Set status-classes on this dom element. + target: 'body', + + // Status + OPENED: "opened", + CLOSED: "closed", + UNKNOWN: "unknown", + + // Admin status + FAKE_CLOSED: "fake_closed", + + // Display values + status_text: { + opened: "ouverte", + closed: "fermée", + unknown: "_____" + }, + force_text: { + activate: "Fermer manuellement", + deactivate: "Réouvrir la K-Fêt" + }, + + get is_recent() { + return this.last_update && moment().diff(this.last_update, 'minute') <= this.time_unknown; + }, + + refresh: function(data) { + if (data) { + $.extend(this, data); + this.last_update = moment(); + } + if (!this.is_recent) + this.status = this.UNKNOWN; + this.refresh_dom(); + }, + + refresh_dom: function() { + let status = this.status; + this.clear_class(); + + this.add_class(status); + this.dom.status_text.html(this.status_text[status]); + + // admin specific + if (this.admin) { + this.add_class(this.admin_status); + if (this.force_close) { + this.dom.warning.show().addClass('in'); + this.dom.force_close_btn.html(this.force_text['deactivate']); + } else { + this.dom.warning.removeClass('in').hide(); + this.dom.force_close_btn.html(this.force_text['activate']); + } + } + }, + + toggle_force_close: function(password) { + $.post({ + url: this.force_close_url, + data: {force_close: !this.force_close}, + beforeSend: function ($xhr) { + $xhr.setRequestHeader("X-CSRFToken", csrftoken); + if (password !== undefined) + $xhr.setRequestHeader("KFetPassword", password); + } + }) + .fail(function($xhr) { + switch ($xhr.status) { + case 403: + requestAuth({'errors': {}}, this.toggle_force_close); + break; + } + }); + }, + + clear_class: function() { + let re = new RegExp('(^|\\s)' + this.class_prefix + '\\S+', 'g'); + $(this.target).attr('class', (i, c) => c ? c.replace(re, '') : ''); + }, + + add_class: function(status) { + $(this.target).addClass(this.class_prefix + status); + } +}; diff --git a/kfet/open/templates/kfetopen/init.html b/kfet/open/templates/kfetopen/init.html new file mode 100644 index 00000000..3834b32a --- /dev/null +++ b/kfet/open/templates/kfetopen/init.html @@ -0,0 +1,13 @@ +{% load static %} + + + + + diff --git a/kfet/open/tests.py b/kfet/open/tests.py new file mode 100644 index 00000000..476eb6c0 --- /dev/null +++ b/kfet/open/tests.py @@ -0,0 +1,334 @@ +import json +from datetime import timedelta +from unittest import mock + +from django.contrib.auth.models import AnonymousUser, Permission, User +from django.test import Client +from django.utils import timezone + +from channels.channel import Group +from channels.test import ChannelTestCase, WSClient + +from . import kfet_open, OpenKfet +from .consumers import OpenKfetConsumer + + +class OpenKfetTest(ChannelTestCase): + """OpenKfet object unit-tests suite.""" + + def setUp(self): + self.kfet_open = OpenKfet() + + def tearDown(self): + self.kfet_open.clear_cache() + + def test_defaults(self): + """Default values.""" + self.assertFalse(self.kfet_open.raw_open) + self.assertIsNone(self.kfet_open.last_update) + self.assertFalse(self.kfet_open.force_close) + self.assertFalse(self.kfet_open.is_open) + + def test_raw_open(self): + """Get and set raw_open; last_update is renewed.""" + for raw_open in [True, False]: + prev_update = self.kfet_open.last_update + self.kfet_open.raw_open = raw_open + self.assertEqual(raw_open, self.kfet_open.raw_open) + self.assertNotEqual(prev_update, self.kfet_open.last_update) + + def test_force_close(self): + """Get and set force_close.""" + for force_close in [True, False]: + self.kfet_open.force_close = force_close + self.assertEqual(force_close, self.kfet_open.force_close) + + def test_is_open(self): + """If force_close is disabled, is_open is raw_open.""" + self.kfet_open.force_close = False + for raw_open in [True, False]: + self.kfet_open.raw_open = raw_open + self.assertEqual(raw_open, self.kfet_open.is_open) + + def test_is_open_force_close(self): + """If force_close is enabled, is_open is False.""" + self.kfet_open.force_close = True + for raw_open in [True, False]: + self.kfet_open.raw_open = raw_open + self.assertFalse(self.kfet_open.is_open) + + def test_status(self): + # (raw_open, force_close, expected status, expected admin) + cases = [ + (False, False, OpenKfet.CLOSED, OpenKfet.CLOSED), + (False, True, OpenKfet.CLOSED, OpenKfet.CLOSED), + (True, False, OpenKfet.OPENED, OpenKfet.OPENED), + (True, True, OpenKfet.CLOSED, OpenKfet.FAKE_CLOSED), + ] + for raw_open, force_close, exp_stat, exp_adm_stat in cases: + self.kfet_open.raw_open = raw_open + self.kfet_open.force_close = force_close + self.assertEqual(exp_stat, self.kfet_open.status()) + self.assertEqual(exp_adm_stat, self.kfet_open.admin_status()) + + def test_status_unknown(self): + self.kfet_open.raw_open = True + self.kfet_open._last_update = timezone.now() - timedelta(days=30) + self.assertEqual(OpenKfet.UNKNOWN, self.kfet_open.status()) + + def test_export_user(self): + """Export is limited for an anonymous user.""" + export = self.kfet_open.export(AnonymousUser()) + self.assertSetEqual( + set(['status']), + set(export), + ) + + def test_export_team(self): + """Export all values for a team member.""" + user = User.objects.create_user('team', '', 'team') + user.user_permissions.add(Permission.objects.get(codename='is_team')) + export = self.kfet_open.export(user) + self.assertSetEqual( + set(['status', 'admin_status', 'force_close']), + set(export), + ) + + def test_send_ws(self): + Group('kfet.open.base').add('test.open.base') + Group('kfet.open.team').add('test.open.team') + + self.kfet_open.send_ws() + + recv_base = self.get_next_message('test.open.base', require=True) + base = json.loads(recv_base['text']) + self.assertSetEqual( + set(['status']), + set(base), + ) + + recv_admin = self.get_next_message('test.open.team', require=True) + admin = json.loads(recv_admin['text']) + self.assertSetEqual( + set(['status', 'admin_status', 'force_close']), + set(admin), + ) + + +class OpenKfetViewsTest(ChannelTestCase): + """OpenKfet views unit-tests suite.""" + + def setUp(self): + # Need this (and here) because of '.login' in setUp + patcher_messages = mock.patch('gestioncof.signals.messages') + patcher_messages.start() + self.addCleanup(patcher_messages.stop) + + # get some permissions + perms = { + 'kfet.is_team': Permission.objects.get(codename='is_team'), + 'kfet.can_force_close': Permission.objects.get(codename='can_force_close'), + } + + # authenticated user and its client + self.u = User.objects.create_user('user', '', 'user') + self.c = Client() + self.c.login(username='user', password='user') + + # team user and its clients + self.t = User.objects.create_user('team', '', 'team') + self.t.user_permissions.add(perms['kfet.is_team']) + self.c_t = Client() + self.c_t.login(username='team', password='team') + + # admin user and its client + self.a = User.objects.create_user('admin', '', 'admin') + self.a.user_permissions.add( + perms['kfet.is_team'], perms['kfet.can_force_close'], + ) + self.c_a = Client() + self.c_a.login(username='admin', password='admin') + + def tearDown(self): + kfet_open.clear_cache() + + def test_door(self): + """Edit raw_status.""" + for sent, expected in [(1, True), (0, False)]: + resp = Client().post('/k-fet/open/raw_open', { + 'raw_open': sent, + 'token': 'plop', + }) + self.assertEqual(200, resp.status_code) + self.assertEqual(expected, kfet_open.raw_open) + + def test_force_close(self): + """Edit force_close.""" + for sent, expected in [(1, True), (0, False)]: + resp = self.c_a.post('/k-fet/open/force_close', {'force_close': sent}) + self.assertEqual(200, resp.status_code) + self.assertEqual(expected, kfet_open.force_close) + + def test_force_close_forbidden(self): + """Can't edit force_close without kfet.can_force_close permission.""" + clients = [Client(), self.c, self.c_t] + for client in clients: + resp = client.post('/k-fet/open/force_close', {'force_close': 0}) + self.assertEqual(403, resp.status_code) + + +class OpenKfetConsumerTest(ChannelTestCase): + """OpenKfet consumer unit-tests suite.""" + + def test_standard_user(self): + """Lambda user is added to kfet.open.base group.""" + # setup anonymous client + c = WSClient() + + # connect + c.send_and_consume('websocket.connect', path='/ws/k-fet/open', + fail_on_none=True) + + # initialization data is replied on connection + self.assertIsNotNone(c.receive()) + + # client belongs to the 'kfet.open' group... + OpenKfetConsumer.group_send('kfet.open.base', {'test': 'plop'}) + self.assertEqual(c.receive(), {'test': 'plop'}) + + # ...but not to the 'kfet.open.admin' one + OpenKfetConsumer.group_send('kfet.open.team', {'test': 'plop'}) + self.assertIsNone(c.receive()) + + @mock.patch('gestioncof.signals.messages') + def test_team_user(self, mock_messages): + """Team user is added to kfet.open.team group.""" + # setup team user and its client + t = User.objects.create_user('team', '', 'team') + t.user_permissions.add( + Permission.objects.get(codename='is_team') + ) + c = WSClient() + c.force_login(t) + + # connect + c.send_and_consume('websocket.connect', path='/ws/k-fet/open', + fail_on_none=True) + + # initialization data is replied on connection + self.assertIsNotNone(c.receive()) + + # client belongs to the 'kfet.open.admin' group... + OpenKfetConsumer.group_send('kfet.open.team', {'test': 'plop'}) + self.assertEqual(c.receive(), {'test': 'plop'}) + + # ... but not to the 'kfet.open' one + OpenKfetConsumer.group_send('kfet.open.base', {'test': 'plop'}) + self.assertIsNone(c.receive()) + + +class OpenKfetScenarioTest(ChannelTestCase): + """OpenKfet functionnal tests suite.""" + + def setUp(self): + # Need this (and here) because of '.login' in setUp + patcher_messages = mock.patch('gestioncof.signals.messages') + patcher_messages.start() + self.addCleanup(patcher_messages.stop) + + # anonymous client (for views) + self.c = Client() + # anonymous client (for websockets) + self.c_ws = WSClient() + + # root user + self.r = User.objects.create_superuser('root', '', 'root') + # its client (for views) + self.r_c = Client() + self.r_c.login(username='root', password='root') + # its client (for websockets) + self.r_c_ws = WSClient() + self.r_c_ws.force_login(self.r) + + def tearDown(self): + kfet_open.clear_cache() + + def ws_connect(self, ws_client): + ws_client.send_and_consume( + 'websocket.connect', path='/ws/k-fet/open', + fail_on_none=True, + ) + return ws_client.receive(json=True) + + def test_scenario_0(self): + """Clients connect.""" + # test for anonymous user + msg = self.ws_connect(self.c_ws) + self.assertSetEqual( + set(['status']), + set(msg), + ) + + # test for root user + msg = self.ws_connect(self.r_c_ws) + self.assertSetEqual( + set(['status', 'admin_status', 'force_close']), + set(msg), + ) + + def test_scenario_1(self): + """Clients connect, door opens, enable force close.""" + self.ws_connect(self.c_ws) + self.ws_connect(self.r_c_ws) + + # door sent "I'm open!" + self.c.post('/k-fet/open/raw_open', { + 'raw_open': True, + 'token': 'plop', + }) + + # anonymous user agree + msg = self.c_ws.receive(json=True) + self.assertEqual(OpenKfet.OPENED, msg['status']) + + # root user too + msg = self.r_c_ws.receive(json=True) + self.assertEqual(OpenKfet.OPENED, msg['status']) + self.assertEqual(OpenKfet.OPENED, msg['admin_status']) + + # admin says "no it's closed" + self.r_c.post('/k-fet/open/force_close', {'force_close': True}) + + # so anonymous user see it's closed + msg = self.c_ws.receive(json=True) + self.assertEqual(OpenKfet.CLOSED, msg['status']) + + # root user too + msg = self.r_c_ws.receive(json=True) + self.assertEqual(OpenKfet.CLOSED, msg['status']) + # but root knows things + self.assertEqual(OpenKfet.FAKE_CLOSED, msg['admin_status']) + self.assertTrue(msg['force_close']) + + def test_scenario_2(self): + """Starting falsely closed, clients connect, disable force close.""" + kfet_open.raw_open = True + kfet_open.force_close = True + + msg = self.ws_connect(self.c_ws) + self.assertEqual(OpenKfet.CLOSED, msg['status']) + + msg = self.ws_connect(self.r_c_ws) + self.assertEqual(OpenKfet.CLOSED, msg['status']) + self.assertEqual(OpenKfet.FAKE_CLOSED, msg['admin_status']) + self.assertTrue(msg['force_close']) + + self.r_c.post('/k-fet/open/force_close', {'force_close': False}) + + msg = self.c_ws.receive(json=True) + self.assertEqual(OpenKfet.OPENED, msg['status']) + + msg = self.r_c_ws.receive(json=True) + self.assertEqual(OpenKfet.OPENED, msg['status']) + self.assertEqual(OpenKfet.OPENED, msg['admin_status']) + self.assertFalse(msg['force_close']) diff --git a/kfet/open/urls.py b/kfet/open/urls.py new file mode 100644 index 00000000..bd227b96 --- /dev/null +++ b/kfet/open/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'^raw_open$', views.raw_open, + name='kfet.open.edit_raw_open'), + url(r'^force_close$', views.force_close, + name='kfet.open.edit_force_close'), +] diff --git a/kfet/open/views.py b/kfet/open/views.py new file mode 100644 index 00000000..4f1efa5f --- /dev/null +++ b/kfet/open/views.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.contrib.auth.decorators import permission_required +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from .open import kfet_open + + +TRUE_STR = ['1', 'True', 'true'] + + +@csrf_exempt +@require_POST +def raw_open(request): + token = request.POST.get('token') + if token != settings.KFETOPEN_TOKEN: + raise PermissionDenied + raw_open = request.POST.get('raw_open') in TRUE_STR + kfet_open.raw_open = raw_open + kfet_open.send_ws() + return HttpResponse() + + +@permission_required('kfet.can_force_close', raise_exception=True) +@require_POST +def force_close(request): + force_close = request.POST.get('force_close') in TRUE_STR + kfet_open.force_close = force_close + kfet_open.send_ws() + return HttpResponse() diff --git a/kfet/routing.py b/kfet/routing.py index 5ea343cb..54de69ae 100644 --- a/kfet/routing.py +++ b/kfet/routing.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * +from channels.routing import include, route_class -from channels.routing import route, route_class -from kfet import consumers +from . import consumers -channel_routing = [ - route_class(consumers.KPsul, path=r"^/ws/k-fet/k-psul/$"), + +routing = [ + route_class(consumers.KPsul, path=r'^/k-psul/$'), + include('kfet.open.routing.routing', path=r'^/open'), ] diff --git a/kfet/signals.py b/kfet/signals.py deleted file mode 100644 index 3dd4d677..00000000 --- a/kfet/signals.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import (absolute_import, division, - print_function, unicode_literals) -from builtins import * - -from django.contrib import messages -from django.contrib.auth.signals import user_logged_in -from django.core.urlresolvers import reverse -from django.dispatch import receiver - -@receiver(user_logged_in) -def messages_on_login(sender, request, user, **kwargs): - if (not user.username == 'kfet_genericteam' - and user.has_perm('kfet.is_team')): - messages.info(request, 'Connexion en utilisateur partagé ?' % reverse('kfet.login.genericteam'), extra_tags='safe') diff --git a/kfet/static/kfet/css/base/buttons.css b/kfet/static/kfet/css/base/buttons.css new file mode 100644 index 00000000..e7498022 --- /dev/null +++ b/kfet/static/kfet/css/base/buttons.css @@ -0,0 +1,88 @@ +/* General ------------------------- */ + +.btn { + border: 0; + outline: none !important; + + transition: background-color, border, color, opacity; + transition-duration: 0.15s; + + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + font-family: "Roboto Slab"; +} + +.btn, .btn-lg, .btn-group-lg>.btn { + border-radius:0; +} + + +/* Default ------------------------- */ + +.btn-default { + background-color: transparent !important; + color: #555; +} + +.btn-default:hover, +.btn-default.focus, .btn-default:focus { + color: #c8102e; +} + +.btn-default[disabled]:hover, .btn-default.disabled:hover { + color: inherit !important; +} + + +/* Primary ------------------------- */ + +.btn-primary { + background-color:#c63b52; + color:#FFF; +} + +.btn-primary:hover, +.btn-primary.focus, .btn-primary:focus, +.btn-primary.active.focus, .btn-primary.active:focus, .btn-primary.active:hover, +.btn-primary:active.focus, .btn-primary:active:focus, .btn-primary:active:hover { + background-color:#c8102e; + color:#FFF; +} + + +/* Primary + White background ------ */ + +.btn-primary-w { + background: white; + color: black; +} + +.btn-primary-w:hover { + background: #c63b52; + color: white; +} + +.btn-primary-w.focus, .btn-primary-w:focus, +.btn-primary-w.active.focus, .btn-primary-w.active:focus, .btn-primary-w.active:hover, +.btn-primary-w:active.focus, .btn-primary-w:active:focus, .btn-primary-w:active:hover { + background: #c8102e; + color: white; +} + + +/* Nav ----------------------------- */ + +.btn-nav { + background-color: transparent !important; + color: inherit; + border-bottom: 1px solid #ddd; +} + +.btn-nav:hover, +.btn-nav.focus, .btn-nav:focus, +.btn-nav.active.focus, .btn-nav.active:focus, .btn-nav.active:hover, +.btn-nav:active.focus, .btn-nav:active:focus, .btn-nav:active:hover { + border-bottom: 1px solid #c8102e; +} diff --git a/kfet/static/kfet/css/base/fixed.css b/kfet/static/kfet/css/base/fixed.css new file mode 100644 index 00000000..d198c50f --- /dev/null +++ b/kfet/static/kfet/css/base/fixed.css @@ -0,0 +1,151 @@ +.fixed > * + * { + margin-top: 15px; +} + + +/* Aside --------------------------- */ + +/* Aside - Block */ + +aside { + background: white; + padding: 15px; +} + +aside > * + * { + margin-top: 15px; +} + +/* Aside - Misc */ + +aside .glyphicon-question-sign { + font-size: 0.8; +} + +aside h4 { + font-weight: bold; +} + +/* Aside - Heading */ + +aside .heading { + font-family: "Roboto Slab"; + font-size: 25px; + font-weight: bold; + line-height: 1.1; + text-align: center; +} + +aside .heading .big { + font-size: 2em; +} + +aside .heading .sub { + font-size: 0.7em; + font-weight: normal; +} + +@media (min-width: 992px) { + aside .heading { + font-size: 32px; + line-height: 1.3; + } +} + +/* Aside - Buttons */ + +aside .buttons { + margin-left: -15px; + margin-right: -15px; +} + +aside .buttons > * { + flex: 0 1 auto !important; +} + + +/* Aside - Text */ + +aside .text { + line-height: 1.3; + font-size: 14px; +} + +@media (min-width: 992px) { + aside .text { + line-height: 1.6; + font-size: 16px; + } +} + +aside .text ul { + margin-bottom: 0; +} + + +/* Buttons ------------------------- */ + +.fixed .buttons { + display: flex; + flex-flow: row wrap; + justify-content: center; + + text-align: center; +} + +.fixed .buttons > * { + flex: 0 1 auto; + overflow: hidden; +} + +.fixed .buttons > .solo { + flex: 1 100%; +} + +@media (min-width: 768px) { + .fixed .buttons > * { + flex: 1 auto; + } + + .fixed .buttons > .full > * { + width: 100%; + } +} + +.fixed .buttons .btn { + padding: 8px 12px; +} + +@media (min-width: 992px) { + .fixed .buttons .btn { + font-size: 16px; + } +} + + +/* Tabs ---------------------------- */ + +.fixed .tabs-buttons { + margin-bottom: -5px; +} + +.fixed .tabs-buttons > * { + margin-bottom: 5px; +} + +.fixed .tabs-buttons .glyphicon-chevron-right { + margin-left: 5px; + line-height: 1.4; + color: white; +} + +@media (min-width: 768px) { + .fixed .tabs-buttons { + text-align: right; + justify-content: flex-end; + } + + .fixed .tabs-buttons > * { + flex: 1 100%; + } +} diff --git a/kfet/static/kfet/css/base/footer.css b/kfet/static/kfet/css/base/footer.css new file mode 100644 index 00000000..abdf98ed --- /dev/null +++ b/kfet/static/kfet/css/base/footer.css @@ -0,0 +1,18 @@ +.footer { + line-height: 40px; + + background: #c63b52; + color: white; + + text-align: center; + font-size: 14px; + font-family: Roboto; +} + +.footer a { + color: inherit !important; +} + +.footer a:hover, .footer a:focus { + text-decoration: underline; +} diff --git a/kfet/static/kfet/css/base/main.css b/kfet/static/kfet/css/base/main.css new file mode 100644 index 00000000..2ebc90d8 --- /dev/null +++ b/kfet/static/kfet/css/base/main.css @@ -0,0 +1,138 @@ +/* Global layout ------------------- */ + +.main-col, .fixed-col { + padding: 0 0 15px; +} + +@media (min-width: 768px) { + .fixed-col { + position: sticky; + top: 35px; + padding-top: 15px; + } + + .fixed-col + .main-col { + padding: 15px 0 15px 15px; + } +} + +@media (min-width: 992px) { + .main-col { + padding: 15px; + } +} + +.main-col-mult { + column-gap: 45px; +} + +.main-bg { + background: white; +} + +.main-padding { + padding: 15px; +} + +@media (min-width: 768px) { + .main-padding { + padding: 30px; + } +} + + +/* Section ------------------------- */ + +section { + margin-bottom: 15px; + position:relative; +} + +section:last-child { + margin-bottom: 0; +} + + +/* Section - Elements -------------- */ + +section > * { + background: white; + padding: 15px; +} + +section > .full, +section > table, +section > .table-responsive { + padding: 0 !important; + margin-left: 0 !important; + margin-right: 0 !important; +} + +section .full { + margin-left: -15px; + margin-right: -15px; +} + +@media (min-width: 992px) { + section > * { + padding: 30px; + } + + section .full { + margin-left: -30px; + margin-right: -30px; + } +} + +section .row > div:last-child { + margin-bottom: 0 !important; +} + +@media (max-width: 768px) { + section .row > div { + margin-bottom: 10px; + } +} + +@media (max-width: 1200px) { + section .row > div { + margin-bottom: 20px; + } +} + +section ul ul { + padding-left: 30px; +} + +/* Titles & Heading */ + +section h2, +section .heading { + background: transparent; + margin: 20px 15px 15px; + padding: 0; + border-bottom: 3px solid #c8102e; + font-family: "Roboto Slab"; + font-size: 40px; + line-height: 1.1; +} + +section h3 { + border-bottom: 2px solid #c8102e; + margin: 0 0 10px; + padding: 10px 0 10px; + font-size: 25px; + font-weight: bold; +} + +section .heading .buttons { + opacity: 0.7; + top: 10px; + float: right; +} + +section h2:first-child, +section h3:first-child { + padding-top: 0; + margin-top: 0; +} diff --git a/kfet/static/kfet/css/base/messages.css b/kfet/static/kfet/css/base/messages.css new file mode 100644 index 00000000..268f514d --- /dev/null +++ b/kfet/static/kfet/css/base/messages.css @@ -0,0 +1,36 @@ +.messages .alert { + padding:10px 15px; + margin:0; + border:0; + border-radius:0; +} + +.messages .alert:last-child { + margin-bottom: 15px; +} + +.messages .alert .close { + top:0; + right:0; +} + +.messages .alert-info { + color:inherit; + background-color:#ccc; +} + +.messages .alert-error { + color: white; + background-color: #c63b52; +} + +.messages .alert-success { + color: white; + background: #3d9947; +} + +.messages a { + font-weight: bold; + text-decoration: none; +} + diff --git a/kfet/static/kfet/css/base/misc.css b/kfet/static/kfet/css/base/misc.css new file mode 100644 index 00000000..680fb1ee --- /dev/null +++ b/kfet/static/kfet/css/base/misc.css @@ -0,0 +1,118 @@ +/* General ------------------------- */ + +body { + margin-top:50px; + font-family:Roboto; + background:#ddd; +} + +.glyphicon + span, span + .glyphicon { + margin-left: 10px; +} + +/* Titles */ + +h1,h2,h3,h4,h5,h6 { + font-family:"Roboto Slab"; +} + +/* Links */ + +a { + color:#C8202E; +} + +a:focus, a:hover { + color:#C8102E; +} + +/* Inputs */ + +:focus { + outline:none; +} + +textarea { + font-family:'Roboto Mono'; + border-radius:0 !important; +} + +/* Lists */ + +ul, ol { + padding-left: 30px; +} + +ul { + list-style-type: square; +} + +/* Tables */ + +.table { + margin-bottom:0; + border-bottom:1px solid #ddd; + width:100%; + background-color: #FFF; +} + +.table td { + vertical-align:middle !important; +} + +.table td.no-padding { + padding:0; +} + +.table thead { + background:#c8102e; + color:#fff; + font-weight:bold; + font-size:16px; +} + +.table thead td { + padding:8px !important; +} + +.table tr.section { + background: #c63b52 !important; + color:#fff; + font-weight:bold; +} + +.table tr.section td { + border-top:0; + font-size:16px; + padding:8px 30px; +} + +.table tr.more td { + padding: 0; +} + +.table-responsive { + border: 0; + margin-bottom: 0; +} + +/* Toggle on hover ----------------- */ + +.toggle:not(:hover) .hover { + display: none; +} + +.toggle:hover .base { + display: none; +} + +/* Spinning animation -------------- */ + +.glyphicon.spinning { + animation: spin 1s infinite linear; +} + +@keyframes spin { + from { transform: scale(1) rotate(0deg); } + to { transform: scale(1) rotate(360deg); } +} diff --git a/kfet/static/kfet/css/base/nav.css b/kfet/static/kfet/css/base/nav.css new file mode 100644 index 00000000..0efc2873 --- /dev/null +++ b/kfet/static/kfet/css/base/nav.css @@ -0,0 +1,151 @@ +.navbar { + background: #000; + color: #DDD; + border: 0; + font-family: Roboto; +} + +.navbar .navbar-header { + float: left; + display: none; + margin-left: -15px; + margin-right: 0; +} + +.navbar .navbar-brand { + padding: 3px 0; + margin: 0 15px !important; +} + +.navbar .navbar-brand img { + height: 44px; +} + +.navbar .navbar-toggle { + border: 0; + border-radius: 0; + padding: 18px 15px; + margin: 0; + min-width: auto; +} + +.navbar .navbar-toggle .icon-bar { + background: #fff; +} + +.navbar-nav { + font-size: 14px; + margin: 0 0 0 -15px; + float: left; +} + +@media (min-width: 460px) { + .navbar .navbar-header { + display: block; + } + + .navbar-nav { + margin-left: 0; + } + + .navbar-nav .nav-pages.dropdown .dropdown-menu > li:first-child { + display: none; + } +} + +.navbar-right { + float: right !important; + margin: 0 -15px 0 0; +} + +.navbar-nav a { + transition: background-color, box-shadow, color; + transition-duration: 0.15s; +} + +.navbar-nav > li { + float: left; + text-align: center; +} + +.navbar-nav > li > a { + min-width: 50px; + padding: 15px 10px; + color: #FFF; +} + +.navbar-nav > .divider { + height: 1px; + background: rgba(255, 255, 255, 0.1); +} + +@media (min-width: 1200px) { + .navbar-nav > li > a { + padding-left: 15px; + padding-right: 15px; + } +} + +.navbar-nav > li > a:hover, .navbar-nav > li > a:focus, +.nav .open > a:hover, .nav .open > a:focus, +.navbar-nav > li.active > a, +.navbar-nav .dropdown:hover > a, .navbar-nav .dropdown:focus > a { + background-color: #C8102E; + color: #FFF; + box-shadow: inset 0 3px 3px -4px #000; +} + +.navbar-nav .dropdown .dropdown-menu { + padding: 0; + border: 0; + border-radius: 0; + background-color: #FFF; + font-size: 14px; + + /* override max-width: 767px of bs */ + position: absolute; + float: left; + box-shadow: 0 6px 12px rgba(0,0,0,.175); +} + +.navbar-nav .dropdown .dropdown-menu > li > a { + padding: 8px 10px; + line-height: inherit; + color: #000; +} + +.navbar-nav .dropdown .dropdown-menu > li > a:hover, +.navbar-nav .dropdown .dropdown-menu > li > a:focus { + color: #c8102e; + background-color: transparent; +} + +.navbar-nav .dropdown .dropdown-menu .divider { + margin: 0; +} + +.navbar-nav .dropdown .dropdown-menu { + display: block; + visibility: hidden; + opacity: 0; + transition: opacity 0.15s; +} + +.navbar-nav .dropdown:hover > .dropdown-menu, +.navbar-nav .dropdown:focus > .dropdown-menu, +.navbar-nav .dropdown.open > .dropdown-menu { + visibility: visible; + opacity: 1; +} + +@media (min-width: 992px) { + .navbar-nav .dropdown .dropdown-menu > li > a { + padding-left: 20px; + padding-right: 20px; + } +} + +.nav-app .dropdown-menu { + right: 0; + left: auto; +} diff --git a/kfet/static/kfet/css/history.css b/kfet/static/kfet/css/history.css index 0e75741a..401aa94d 100644 --- a/kfet/static/kfet/css/history.css +++ b/kfet/static/kfet/css/history.css @@ -9,17 +9,21 @@ #history .day { height:40px; line-height:40px; - background-color:#c8102e; + background-color:rgba(200,16,46,1); color:#fff; padding-left:20px; + font-family:"Roboto Slab"; font-size:16px; font-weight:bold; + position:sticky; + top:50px; + z-index:10; } #history .opegroup { height:30px; line-height:30px; - background-color:rgba(200,16,46,0.85); + background-color: #c63b52; color:#fff; font-weight:bold; padding-left:20px; diff --git a/kfet/static/kfet/css/home.css b/kfet/static/kfet/css/home.css deleted file mode 100644 index 718159c3..00000000 --- a/kfet/static/kfet/css/home.css +++ /dev/null @@ -1,54 +0,0 @@ -ul.carte { - width: 100%; - list-style-type: none; - padding-left: 15px; - padding-right: 15px; - display: inline-block; - *display: inline; - zoom: 1; - position: relative; - clip: auto; - overflow: hidden; -} -/* -ul.carte > li { - border-style: none none solid none; - border-width: 1px; - border-color: #DDD; -} -*/ -li.carte-line { - position: relative; - text-align: right; - white-space: nowrap; -} -.filler { - position: absolute; - left: 0; - right: 0; - border-bottom: 3px dotted #333; - height: 70%; -} -.carte-label { - background: white; - float: left; - padding-right: 4px; - position: relative; - max-width: calc(100% - 40px); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.carte-ukf { - background: white; - padding-left: 4px; - position: relative; -} - - -.unbreakable.carte-inverted .carte-ukf, -.unbreakable.carte-inverted .carte-label, -.unbreakable.carte-inverted { - background: #FFDBC7; -} diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index 0244a57b..8e28cce0 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -1,264 +1,116 @@ -@import url("nav.css"); +/* Libs */ +@import url("libs/columns.css"); + +/* Libs customizations */ +@import url("libs/jconfirm-kfet.css"); +@import url("libs/multiple-select-kfet.css"); + +/* Base */ +@import url("base/misc.css"); +@import url("base/buttons.css"); + +/* Blocks */ +@import url("base/main.css"); +@import url("base/nav.css"); +@import url("base/messages.css"); +@import url("base/fixed.css"); +@import url("base/footer.css"); + +/* Components */ @import url("kpsul.css"); -@import url("jconfirm-kfet.css"); @import url("history.css"); -body { - margin-top:50px; - font-family:Roboto; - background:#ddd; + + +.header { + padding: 15px 20px; + + background-color: rgba(200,16,46,1); + color: #FFF; } -h1,h2,h3,h4,h5,h6 { - font-family:Oswald; -} - -a { - color:#333; -} - -a:focus, a:hover { - color:#C8102E; -} - -:focus { - outline:none; -} - -textarea { - font-family:'Roboto Mono'; - border-radius:0 !important; -} - -.table { - margin-bottom:0; - border-bottom:1px solid #ddd; -} - -.table { - width:100%; -} - -.table td { - vertical-align:middle !important; -} - -.table td.no-padding { - padding:0; -} - -.table thead { - background:#c8102e; - color:#fff; - font-weight:bold; - font-size:16px; -} - -.table thead td { - padding:8px !important; -} - -.table tr.section { - background:rgba(200,16,46,0.9); - color:#fff; - font-weight:bold; -} - -.table tr.section td { - border-top:0; - font-size:16px; - padding:8px 30px; -} - -.btn, .btn-lg, .btn-group-lg>.btn { - border-radius:0; -} - -.btn-primary { - font-family:Oswald; - background-color:rgba(200,16,46,0.9); - color:#FFF; - border:0; -} - -.btn-primary:hover, .btn-primary.focus, .btn-primary:focus { - background-color:#000; - color:#FFF; -} - -.buttons .nav-pills > li > a { - border-radius:0; - border:1px solid rgba(200,16,46,0.9); -} - -.buttons .nav-pills > li.active > a { - background-color:rgba(200,16,46,0.9); - background-clip:padding-box; -} - -.row-page-header { - background-color:rgba(200,16,46,1); - color:#FFF; - border-bottom:3px solid #000; -} - -.page-header { - border:0; - padding:0; - margin:15px 20px; - text-transform:uppercase; - font-weight:bold; +.header h1 { + padding: 0; + margin: 0; + font-weight: bold; } .nopadding { padding: 0 !important; } -.panel-md-margin{ - background-color: white; - overflow:hidden; - padding-left: 15px; - padding-right: 15px; - padding-bottom: 15px; - padding-top: 1px; -} - -@media (min-width: 992px) { - .panel-md-margin{ - margin:8px; - background-color: white; - } -} - -.col-content-left, .col-content-right { - padding:0; -} - -.content-left-top { - background:#fff; - padding:10px 30px; -} - -.content-left .buttons { - background:#fff; -} - -.content-left .buttons .btn { - display:block; -} - -.content-left-top.frozen-account { - background:#000FBA; +.frozen-account { + background:#5072e0; color:#fff; } -.content-left .block { - padding-top:25px; + +.main .table a:not(.btn) { + color: inherit; } -.content-left .block .line { - font-size:16px; - line-height:30px; +.main .table a:not(.btn):focus , +.main .table a:not(.btn):hover { + color: #C81022; } -.content-left .line.line-big { - font-family:Oswald; - font-size:60px; - font-weight:bold; - text-align:center; -} - -.content-left .line.line-bigsub { - font-size:25px; - font-weight:bold; - text-align:center; -} - -.content-left .line.balance { - font-size:45px; - text-align:center; -} - -.content-right { - margin:0 15px; -} - -.content-right-block { - padding-bottom:5px; - position:relative; -} - -.content-right-block:last-child { - padding-bottom:15px; -} - -.content-right-block > div:not(.buttons-title) { - background:#fff; -} - -.content-right-block-transparent > div:not(.buttons-title) { - background-color: transparent; -} - -.content-right-block .buttons-title { - position:absolute; - top:8px; - right:20px; -} - -.content-right-block > div.row { - margin:0; -} - -.content-right-block h2 { - margin:20px 20px 15px; - padding-bottom:5px; - border-bottom:3px solid #c8102e; - font-size:40px; -} - -.content-right-block h3 { - border-bottom: 1px solid #c8102e; - margin: 20px 15px 15px; - padding-bottom: 10px; - padding-left: 20px; - font-size:25px; -} /* * Pages tableaux seuls */ -.content-center > div { - background:#fff; -} -.content-center tbody tr:not(.section) td { - padding:0px 5px !important; -} - -.content-center .table .form-control { +.table .form-control { padding: 1px 12px ; height:28px; margin:3px 0px; + background: transparent; } - .content-center .auth-form { - margin:15px; + +.table .form-control[disabled], .table .form-control[readonly] { + background: #f5f5f5; +} + +.table-condensed-input tbody tr:not(.section) td { + padding:0px 5px; +} + +.table-condensed input.form-control { + margin: 0 !important; + border-top: 0; + border-bottom: 0; + border-radius: 0; +} + +.auth-form { + padding: 15px 0; + background: #d86c7e; + color: white; +} + +.auth-form.form-horizontal { + padding: 0; + margin: 0; +} + +.auth-form .form-group { + margin-bottom: 0; +} + +.auth-form input { + box-shadow: none !important; + background: transparent; + color: white; + border: 0 !important; + border-radius: 0; + border-bottom: 1px solid white !important; } /* * Pages formulaires seuls */ -.form-only .content-form { - margin:15px; - - background:#fff; - - padding:15px; -} - -.form-only .account_create #id_trigramme { +.account_create #id_trigramme { display:block; width:200px; height:80px; @@ -320,38 +172,48 @@ textarea { padding:5px 20px; } -/* - * Messages +/* Account autocomplete window */ + +#account_results ul { + list-style-type:none; + background:rgba(255,255,255,0.9); + padding:0; +} + +#account_results li { + display:block; + padding:5px 20px; + height:100%; + width:100%; +} + +#account_results .hilight { + background:rgba(200,16,46,0.9); + color:#fff; + text-decoration:none; +} + +/** + * Stats (graphs) */ -.messages .alert { - padding:10px 15px; - margin:0; - border:0; - border-radius:0; +.stat-nav { + margin-bottom: 10px; + font-family: Roboto; } -.messages .alert-dismissible { - padding-right:35px; +.stat-nav li { + float: left; } -.messages .alert .close { - top:0; - right:0; +.stat-nav a { + opacity: 0.6; + font-family: Roboto; } -.messages .alert-info { - color:inherit; - background-color:#ccc; -} - -.messages .alert-error { - color:inherit; - background-color:rgba(200,16,46,0.2); -} - -.messages .alert-success { - color:#333; +.stat-nav a:hover, +.stat-nav a.focus, .stat-nav a:focus { + opacity: 1; } /* @@ -374,7 +236,7 @@ textarea { margin-top:30px; padding-top:1px; padding-bottom:15px; - background:rgba(51,51,51,0.7); + background:rgba(51,51,51,0.9); color:#fff; } @@ -411,171 +273,56 @@ thead .tooltip { height: 100px; } -/* - * Responsive Columns - */ - -.unbreakable { - display:inline-block; - width: 100%; -} - - -.column-row { - padding: 15px 20px; -} - -.column-xs-1, -.column-sm-1, -.column-md-1, -.column-lg-1, -.column-xs-2, -.column-sm-2, -.column-md-2, -.column-lg-2, -.column-xs-3, -.column-sm-3, -.column-md-3, -.column-lg-3, -.column-xs-4, -.column-sm-4, -.column-md-4, -.column-lg-4, -.column-xs-5, -.column-sm-5, -.column-md-5, -.column-lg-5 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; -} - - -.column-xs-1 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; -} -.column-xs-2 { - -webkit-column-count: 2; /* Chrome, Safari, Opera */ - -moz-column-count: 2; /* Firefox */ - column-count: 2; -} -.column-xs-3 { - -webkit-column-count: 3; /* Chrome, Safari, Opera */ - -moz-column-count: 3; /* Firefox */ - column-count: 3; -} -.column-xs-4 { - -webkit-column-count: 4; /* Chrome, Safari, Opera */ - -moz-column-count: 4; /* Firefox */ - column-count: 4; -} -.column-xs-5 { - -webkit-column-count: 5; /* Chrome, Safari, Opera */ - -moz-column-count: 5; /* Firefox */ - column-count: 5; -} - -@media (min-width: 576px) { - .column-sm-1 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; - } - .column-sm-2 { - -webkit-column-count: 2; /* Chrome, Safari, Opera */ - -moz-column-count: 2; /* Firefox */ - column-count: 2; - } - .column-sm-3 { - -webkit-column-count: 3; /* Chrome, Safari, Opera */ - -moz-column-count: 3; /* Firefox */ - column-count: 3; - } - .column-sm-4 { - -webkit-column-count: 4; /* Chrome, Safari, Opera */ - -moz-column-count: 4; /* Firefox */ - column-count: 4; - } - .column-sm-5 { - -webkit-column-count: 5; /* Chrome, Safari, Opera */ - -moz-column-count: 5; /* Firefox */ - column-count: 5; - } -} - -@media (min-width: 768px) { - .column-md-1 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; - } - .column-md-2 { - -webkit-column-count: 2; /* Chrome, Safari, Opera */ - -moz-column-count: 2; /* Firefox */ - column-count: 2; - } - .column-md-3 { - -webkit-column-count: 3; /* Chrome, Safari, Opera */ - -moz-column-count: 3; /* Firefox */ - column-count: 3; - } - .column-md-4 { - -webkit-column-count: 4; /* Chrome, Safari, Opera */ - -moz-column-count: 4; /* Firefox */ - column-count: 4; - } - .column-md-5 { - -webkit-column-count: 5; /* Chrome, Safari, Opera */ - -moz-column-count: 5; /* Firefox */ - column-count: 5; - } -} - -@media (min-width: 992px) { - .column-lg-1 { - -webkit-column-count: 1; /* Chrome, Safari, Opera */ - -moz-column-count: 1; /* Firefox */ - column-count: 1; - } - .column-lg-2 { - -webkit-column-count: 2; /* Chrome, Safari, Opera */ - -moz-column-count: 2; /* Firefox */ - column-count: 2; - } - .column-lg-3 { - -webkit-column-count: 3; /* Chrome, Safari, Opera */ - -moz-column-count: 3; /* Firefox */ - column-count: 3; - } - .column-lg-4 { - -webkit-column-count: 4; /* Chrome, Safari, Opera */ - -moz-column-count: 4; /* Firefox */ - column-count: 4; - } - .column-lg-5 { - -webkit-column-count: 5; /* Chrome, Safari, Opera */ - -moz-column-count: 5; /* Firefox */ - column-count: 5; - } -} - -.help-block { - padding-top: 15px; -} /* Inventaires */ +.table-condensed-input input[type=number] { + text-align: center; +} + .inventory_modified { background:rgba(236,100,0,0.15); } .stock_diff { padding-left: 5px; - color:#C8102E; + color:#C8102E; } .inventory_update { - display:none; + display: none; + width: 50px; + margin: 0 auto; +} + + +/* Checkbox select multiple */ + +.checkbox-select-multiple label { + font-weight: normal; + margin-bottom: 0; +} + +/* Statement creation */ + +.statement-create-summary table { + margin: 0 auto; +} + +.statement-create-summary tr td { + text-align: right; +} + +.statement-create-summary tr td:first-child { + padding-right: 15px; + font-weight: bold; +} + +.statement-create-summary tr td:last-child { + width: 80px; +} + +#detail_taken table td, +#detail_balance table td { + padding: 0; } diff --git a/kfet/static/kfet/css/kpsul.css b/kfet/static/kfet/css/kpsul.css index 16f33922..e7c4f360 100644 --- a/kfet/static/kfet/css/kpsul.css +++ b/kfet/static/kfet/css/kpsul.css @@ -18,6 +18,17 @@ input[type=number]::-webkit-outer-spin-button { 100% { background: yellow; } } +/* Announcements banner */ + +#banner { + background-color: #d86b01; + width: 100%; + text-align: center; + padding: 10px; + color: white; + font-size: larger; +} + /* * Top row */ @@ -147,9 +158,10 @@ input[type=number]::-webkit-outer-spin-button { height:50px; padding:0 15px; - background:#c8102e; + background:rgba(200,16,46,1); color:#fff; + font-family:"Roboto Slab"; font-weight:bold; font-size:18px; } @@ -230,24 +242,17 @@ input[type=number]::-webkit-outer-spin-button { height:40px; } -#special_operations button { - height:100%; - width:25%; +#special_operations .btn { + height:40px; - float:left; - - background:#c8102e; - color:#FFF; - - font-size:18px; + font-size:15px; font-weight:bold; + + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; } -#special_operations button:focus, #special_operations button:hover { - outline:none; - background:#000; - color:#fff; -} /* Article autocomplete */ @@ -262,14 +267,14 @@ input[type=number]::-webkit-outer-spin-button { height:100%; float:left; border:0; - border-right:1px solid #c8102e; border-radius:0; + border-bottom: 1px solid rgba(200,16,46,0.9); font-size:16px; font-weight:bold; } -#article_selection input+input #article_selection input+span { - border-right:0; +#article_selection input:first-child { + border-right: 1px dashed rgba(200,16,46,0.9); } #article_autocomplete { @@ -328,14 +333,14 @@ input[type=number]::-webkit-outer-spin-button { width:14%; } - #articles_data div.category { height:35px; line-height:35px; background-color:#c8102e; + font-family:"Roboto Slab"; font-size:16px; - color:#FFF; font-weight:bold; + color:#FFF; } #articles_data div.category>span:first-child { @@ -459,3 +464,7 @@ input[type=number]::-webkit-outer-spin-button { .kpsul_middle_right_col { overflow:auto; } + +.kpsul_middle_right_col #history .day { + top: 0; +} diff --git a/kfet/static/kfet/css/libs/columns.css b/kfet/static/kfet/css/libs/columns.css new file mode 100644 index 00000000..34591061 --- /dev/null +++ b/kfet/static/kfet/css/libs/columns.css @@ -0,0 +1,43 @@ +.unbreakable { + display:inline-block; + width: 100%; +} + +.column-xs-1, .column-sm-1, .column-md-1, .column-lg-1, +.column-xs-2, .column-sm-2, .column-md-2, .column-lg-2, +.column-xs-3, .column-sm-3, .column-md-3, .column-lg-3, +.column-xs-4, .column-sm-4, .column-md-4, .column-lg-4, +.column-xs-5, .column-sm-5, .column-md-5, .column-lg-5 { + column-count: 1; +} + +.column-xs-1 { column-count: 1; } +.column-xs-2 { column-count: 2; } +.column-xs-3 { column-count: 3; } +.column-xs-4 { column-count: 4; } +.column-xs-5 { column-count: 5; } + +@media (min-width: 768px) { + .column-sm-1 { column-count: 1; } + .column-sm-2 { column-count: 2; } + .column-sm-3 { column-count: 3; } + .column-sm-4 { column-count: 4; } + .column-sm-5 { column-count: 5; } +} + +@media (min-width: 992px) { + .column-md-1 { column-count: 1; } + .column-md-2 { column-count: 2; } + .column-md-3 { column-count: 3; } + .column-md-4 { column-count: 4; } + .column-md-5 { column-count: 5; } +} + +@media (min-width: 1200px) { + .column-lg-1 { column-count: 1; } + .column-lg-2 { column-count: 2; } + .column-lg-3 { column-count: 3; } + .column-lg-4 { column-count: 4; } + .column-lg-5 { column-count: 5; } +} + diff --git a/kfet/static/kfet/css/jconfirm-kfet.css b/kfet/static/kfet/css/libs/jconfirm-kfet.css similarity index 81% rename from kfet/static/kfet/css/jconfirm-kfet.css rename to kfet/static/kfet/css/libs/jconfirm-kfet.css index bb8ba849..74f9c857 100644 --- a/kfet/static/kfet/css/jconfirm-kfet.css +++ b/kfet/static/kfet/css/libs/jconfirm-kfet.css @@ -5,7 +5,7 @@ .jconfirm .jconfirm-box { padding:0; border-radius:0 !important; - font-family:"Roboto Mono"; + font-family:Roboto; } .jconfirm .jconfirm-box div.title-c .title { @@ -28,7 +28,6 @@ .jconfirm .jconfirm-box .content { border-bottom:1px solid #ddd; - padding:5px 10px; } .jconfirm .jconfirm-box input { @@ -37,6 +36,7 @@ border:0; + font-family:"Roboto Mono"; font-size:40px; text-align:center; @@ -49,6 +49,7 @@ } .jconfirm .jconfirm-box .buttons button { + min-width:40px; height:100%; margin:0; margin:0 !important; @@ -89,24 +90,3 @@ padding-right: 50px; padding-left: 50px; } - -/* Account autocomplete window */ - -#account_results ul { - list-style-type:none; - background:rgba(255,255,255,0.9); - padding:0; -} - -#account_results li { - display:block; - padding:5px 20px; - height:100%; - width:100%; -} - -#account_results .hilight { - background:rgba(200,16,46,0.9); - color:#fff; - text-decoration:none; -} diff --git a/kfet/static/kfet/css/libs/multiple-select-kfet.css b/kfet/static/kfet/css/libs/multiple-select-kfet.css new file mode 100644 index 00000000..145968d3 --- /dev/null +++ b/kfet/static/kfet/css/libs/multiple-select-kfet.css @@ -0,0 +1,14 @@ +/** + * Multiple Select plugin customizations + */ + +.ms-choice { + height: 34px !important; + line-height: 34px !important; + border: 1px solid #ccc !important; + box-shadow: inset 0 1px 1px rgba(0,0,0,.075) !important; +} + +.ms-choice > div { + top: 4px !important; +} diff --git a/kfet/static/kfet/css/nav.css b/kfet/static/kfet/css/nav.css index 5ffc7b24..bec05ccf 100644 --- a/kfet/static/kfet/css/nav.css +++ b/kfet/static/kfet/css/nav.css @@ -1,67 +1,88 @@ -nav { - background:#000; - color:#DDD; - font-family:Oswald; +.navbar { + background: #000; + color: #DDD; + font-family: Oswald; + border: 0; } -.navbar-nav > li > .dropdown-menu { - border:0; - border-radius:0; +.navbar .navbar-brand { + padding: 3px 25px; } -.navbar-fixed-top { - border:0; +.navbar .navbar-brand img { + height: 44px; } -nav .navbar-brand { - padding:3px 25px; -} - -nav .navbar-brand img { - height:44px; -} - -nav .navbar-toggle .icon-bar { - background-color:#FFF; -} - -nav a { - color:#DDD; +.navbar .navbar-toggle .icon-bar { + background-color: #FFF; } .navbar-nav { - font-weight:bold; - font-size:14px; - text-transform:uppercase; + font-weight: bold; + font-size: 14px; + text-transform: uppercase; + margin: 0 -15px; } -.nav>li>a:focus, .nav>li>a:hover { - background-color:#C8102E; - color:#FFF; -} - -.nav .open>a, .nav .open>a:focus, .nav .open>a:hover { - background-color:#C8102E; -} - -.dropdown-menu { - padding:0; -} - -.dropdown-menu>li>a { - padding:8px 20px; -} - -.dropdown-menu .divider { - margin:0; -} - -@media (max-width: 767px) { - .navbar-nav .open .dropdown-menu { - background-color:#FFF; - } - +@media (min-width: 768px) { .navbar-nav { - margin:0 -15px; + margin: 0px; + } + .navbar-right { + margin-right: -15px; + } +} + +.navbar-nav a { + transition: background-color, box-shadow, color; + transition-duration: 0.15s; +} + +.navbar-nav > li > a { + color: #FFF; +} + +.navbar-nav > li:hover > a, +.navbar-nav > li > a:focus, +.nav .open > a:hover, +.nav .open > a:focus { + background-color: #C8102E; + color: #FFF; + box-shadow: inset 0 5px 5px -5px #000; +} + +.navbar .dropdown .dropdown-menu { + padding: 0; + border: 0; + border-radius: 0; + background-color: #FFF; +} + +.navbar .dropdown .dropdown-menu > li > a { + padding: 8px 20px; + color: #000; +} + +.navbar .dropdown .dropdown-menu > li > a:hover, +.navbar .dropdown .dropdown-meny > li > a:focus { + color: #c8102e; + background-color: transparent; +} + +.navbar .dropdown .dropdown-menu .divider { + margin: 0; +} + +@media (min-width: 768px) { + .navbar .dropdown .dropdown-menu { + display: block; + visibility: hidden; + opacity: 0; + transition: opacity 0.15s; + } + + .navbar .dropdown:hover .dropdown-menu { + visibility: visible; + opacity: 1; } } diff --git a/kfet/static/kfet/img/favicon.png b/kfet/static/kfet/img/favicon.png new file mode 100644 index 00000000..56fb4299 Binary files /dev/null and b/kfet/static/kfet/img/favicon.png differ diff --git a/kfet/static/kfet/js/kfet.js b/kfet/static/kfet/js/kfet.js index 268de3b9..69b0478f 100644 --- a/kfet/static/kfet/js/kfet.js +++ b/kfet/static/kfet/js/kfet.js @@ -135,37 +135,37 @@ class Config { } +/* + * CSRF Token + */ -$(document).ready(function() { - $(window).scroll(function() { - if ($(window).width() >= 768 && $(this).scrollTop() > 72.6) { - $('.col-content-left').css({'position':'fixed', 'top':'50px'}); - $('.col-content-right').addClass('col-sm-offset-4 col-md-offset-3'); - } else { - $('.col-content-left').css({'position':'relative', 'top':'0'}); - $('.col-content-right').removeClass('col-sm-offset-4 col-md-offset-3'); - } - }); +var csrftoken = ''; +if (typeof Cookies !== 'undefined') + csrftoken = Cookies.get('csrftoken'); - 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)); +// 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}) + ); +} + + /* * Capslock management */ @@ -196,19 +196,24 @@ $(document).on('keydown', function(e) { class KfetWebsocket { static get defaults() { - return {"relative_url": "", "default_msg": {}, "handlers": []}; + return { + relative_url: '', + default_msg: {}, + handlers: [], + base_path: '/ws/k-fet/' + }; } constructor(data) { $.extend(this, this.constructor.defaults, data); + if (window.location.pathname.startsWith('/gestion/')) + this.base_path = '/gestion' + this.base_path; } get url() { - var websocket_protocol = window.location.protocol == 'https:' ? 'wss' : 'ws'; - var location_host = window.location.host; - var location_url = window.location.pathname.startsWith('/gestion/') ? location_host + '/gestion' : location_host; - - return websocket_protocol+"://" + location_url + this.relative_url ; + var protocol = window.location.protocol == 'https:' ? 'wss' : 'ws'; + var host = window.location.host; + return protocol + "://" + host + this.base_path + this.relative_url; } add_handler(handler) { @@ -232,7 +237,7 @@ class KfetWebsocket { } var OperationWebSocket = new KfetWebsocket({ - 'relative_url': '/ws/k-fet/k-psul/', + 'relative_url': 'k-psul/', 'default_msg': {'opegroups':[],'opes':[],'checkouts':[],'articles':[]}, }); @@ -427,3 +432,37 @@ String.prototype.pluralize = function(count, irreg_plural) { return irreg_plural ? irreg_plural : this+'s' ; return this ; } + +/** + * Setup jquery-confirm + */ + +jconfirm.defaults = { + confirmButton: '', + cancelButton: '' +}; + + +/** + * Create form node, given an url used as 'action', with csrftoken set. + */ +function create_form(url) { + let $form = $('', { + 'action': url, + 'method': 'post', + }); + add_csrf_form($form); + return $form; +} + + +/** + * Emit a POST request from tag. + * + * Usage: + * {…} + */ +function submit_url(el) { + let url = $(el).data('url'); + create_form(url).appendTo($('body')).submit(); +} diff --git a/kfet/static/kfet/js/statistic.js b/kfet/static/kfet/js/statistic.js index db31e0e8..9baa08c4 100644 --- a/kfet/static/kfet/js/statistic.js +++ b/kfet/static/kfet/js/statistic.js @@ -7,7 +7,7 @@ var self = this; var element = $(target); - var content = $("
"); + var content = $("
"); var buttons; function dictToArray (dict, start) { @@ -67,7 +67,6 @@ { label: chart.label, borderColor: chart.color, - backgroundColor: chart.color, fill: is_time_chart, lineTension: 0, data: chart_data, @@ -154,9 +153,8 @@ // initialize the interface function initialize (data) { // creates the bar with the buttons - buttons = $("
", - {class: "btn-group btn-group-justified", - role: "group", + buttons = $("
    ", + {class: "nav stat-nav", "aria-label": "select-period"}); var to_click; @@ -164,11 +162,9 @@ for (var i = 0; i < context.length; i++) { // creates the button - var btn_wrapper = $("
    ", - {class: "btn-group", - role:"group"}); - var btn = $("
    +
+{% endif %} + +{% endblock %} + +{% block main %} + +
+ + + + + + + + + + + + + + + {% for neg in negatives %} + + + + + + + + + + + {% endfor %} + +
Tri.NomBalanceRéelleDébutDécouvert autoriséJusqu'auBalance offset
+ + {{ neg.account.trigramme }} + + {{ neg.account.name }}{{ neg.account.balance|floatformat:2 }}€ + {% if neg.balance_offset %} + {{ neg.account.real_balance|floatformat:2 }}€ + {% endif %} + {{ neg.start|date:'d/m/Y H:i:s'}}{{ neg.authz_overdraft_amount|default_if_none:'' }}{{ neg.authz_overdrafy_until|default_if_none:'' }}{{ neg.balance_offset|default_if_none:'' }}
{% endblock %} diff --git a/kfet/templates/kfet/account_read.html b/kfet/templates/kfet/account_read.html index 58f1f4a9..a604cec3 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -1,18 +1,9 @@ -{% extends "kfet/base.html" %} +{% extends "kfet/base_col_2.html" %} {% load staticfiles %} {% load kfet_tags %} {% load l10n %} {% block extra_head %} - - - - - - - - - {% if account.user == request.user %} @@ -29,20 +20,12 @@ $(document).ready(function() { "{% url 'kfet.account.stat.balance.list' trigramme=account.trigramme %}", $("#stat_balance") ); - }); +}); {% endif %} {% endblock %} {% block title %} -{% if account.user == request.user %} - Mon compte -{% else %} - Informations du compte {{ account.trigramme }} -{% endif %} -{% endblock %} - -{% block content-header-title %} {% if account.user == request.user %} Mon compte {% else %} @@ -50,53 +33,56 @@ $(document).ready(function() { {% endif %} {% endblock %} -{% block content %} +{% block header-title %} +{% if account.user == request.user %} + Mon compte +{% else %} + Informations du compte {{ account.trigramme }} +{% endif %} +{% endblock %} -
-
-
- {% include 'kfet/left_account.html' %} -
-
-
- {% include "kfet/base_messages.html" %} -
-
-
- {% if account.user == request.user %} -
-
-

Statistiques

-
-

Ma balance

-
-

Ma consommation

-
-
-
-
- {% endif %} - {% if addcosts %} -

Gagné des majorations

-
-
    - {% for addcost in addcosts %} -
  • {{ addcost.date|date:'l j F' }}: +{{ addcost.sum_addcosts }}€
  • - {% endfor %} -
-
- {% endif %} -

Historique

-
- {% if account.user == request.user %} -
-
- {% endif %} -
-
-
-
-
+{% block footer %} +{% include "kfet/base_footer.html" %} +{% endblock %} + +{% block fixed %} +{% include "kfet/left_account.html" %} +{% endblock %} + +{% block main %} + +
+ + {% if account.user == request.user %} +
+
+
+

Ma balance

+
+

Ma consommation

+
+
+
+
+ {% endif %} + +
+
+ {% if addcosts %} +

Gagné des majorations

+
+
    + {% for addcost in addcosts %} +
  • {{ addcost.date|date:'l j F' }}: +{{ addcost.sum_addcosts }}€
  • + {% endfor %} +
+
+ {% endif %} +
+
+
+ +
{% endblock %} -{% block title %}Informations sur l'article {{ article }}{% endblock %} -{% block content-header-title %}Article - {{ article.name }}{% endblock %} +{% block title %}Article - {{ article.name }}{% endblock %} +{% block header-title %}Informations sur l'article {{ article.name }}{% endblock %} -{% block content %} +{% block fixed %} -
-
-
-
-
{{ article.name }}
-
{{ article.category }}
-
-
Prix (hors réduc.): {{ article.price }}€
-
Stock: {{ article.stock }}
-
En vente: {{ article.is_sold | yesno:"Oui,Non" }}
-
Affiché: {{ article.hidden | yesno:"Non,Oui" }}
+ + + + +{% endblock %} + +{% block main %} + +
+ +
+ +
+
+
+
+ +

Inventaires récents

+
+ {% include "kfet/article_inventories_snippet.html" with inventoryarts=inventoryarts|slice:5 %}
-
- -
+ +
+
+ +

Derniers prix fournisseurs

+
+ {% include "kfet/article_suppliers_snippet.html" with supplierarts=supplierarts|slice:5 %} +
+ +
+
-
- {% include 'kfet/base_messages.html' %} -
-
-

Historique

-
-
-

Inventaires

- - - - - - - - - - {% for inventoryart in inventoryarts %} - - - - - - {% endfor %} - -
DateStockErreur
{{ inventoryart.inventory.at }}{{ inventoryart.stock_new }}{{ inventoryart.stock_error }}
-
-
-

Prix fournisseurs

- - - - - - - - - - - - {% for supplierart in supplierarts %} - - - - - - - - {% endfor %} - -
DateFournisseurHTTVADroits
{{ supplierart.at }}{{ supplierart.supplier.name }}{{ supplierart.price_HT }}{{ supplierart.TVA }}{{ supplierart.rights }}
-
-
-
-
-

Statistiques

-
-
-
-

Ventes de {{ article.name }}

-
-
-
-
-
-
+ + +
+
+

Ventes

+
+
+ +
+ +
+
+ {% include "kfet/article_inventories_snippet.html" %} +
+
+ +
+
+ {% include "kfet/article_suppliers_snippet.html" %} +
+
+
diff --git a/kfet/templates/kfet/article_suppliers_snippet.html b/kfet/templates/kfet/article_suppliers_snippet.html new file mode 100644 index 00000000..bd5970fd --- /dev/null +++ b/kfet/templates/kfet/article_suppliers_snippet.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + {% for supplierart in supplierarts %} + + + + + + + + {% endfor %} + +
DateFournisseurHTTVADroits
{{ supplierart.at }}{{ supplierart.supplier.name }}{{ supplierart.price_HT|default_if_none:"" }}{{ supplierart.TVA|default_if_none:"" }}{{ supplierart.rights|default_if_none:"" }}
diff --git a/kfet/templates/kfet/article_update.html b/kfet/templates/kfet/article_update.html index a3bfbcc6..3f09e48e 100644 --- a/kfet/templates/kfet/article_update.html +++ b/kfet/templates/kfet/article_update.html @@ -1,27 +1,10 @@ -{% extends 'kfet/base.html' %} -{% load widget_tweaks %} -{% load staticfiles %} +{% extends "kfet/base_form.html" %} -{% block title %}Édition de l'article {{ article.name }}{% endblock %} -{% block content-header-title %}Article {{ article.name }} - Édition{% endblock %} +{% block title %}{{ article.name }} - Édition{% endblock %} +{% block header-title %}Édition de l'article {{ article.name }}{% endblock %} -{% block content %} +{% block main %} -{% include "kfet/base_messages.html" %} - -
-
-
-
- {% csrf_token %} - {% include 'kfet/form_snippet.html' with form=form %} - {% if not perms.kfet.change_article %} - {% include 'kfet/form_authentication_snippet.html' %} - {% endif %} - {% include 'kfet/form_submit_snippet.html' with value="Mettre à jour" %} -
-
-
-
+{% include "kfet/form_full_snippet.html" with authz=perms.kfet.change_article submit_text="Mettre à jour"%} {% endblock %} diff --git a/kfet/templates/kfet/base.html b/kfet/templates/kfet/base.html index 173a5fb7..f9ce0eab 100644 --- a/kfet/templates/kfet/base.html +++ b/kfet/templates/kfet/base.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static menu_tags %} @@ -7,17 +7,31 @@ {% block title %}{% endblock %} | K-Fêt - ENS Ulm + + + + {# CSS #} - + + {# JS #} + + + + + + + + + {% include "kfetopen/init.html" %} {% block extra_head %}{% endblock %} @@ -28,18 +42,26 @@ - {% include "kfet/base_nav.html" %} + {% flat_menu "kfet-nav" template="kfet/base_nav.html" apply_active_classes=True %}
- {% block content-header %} -
-
-

{% block content-header-title %}{% endblock %}

+ {% block header %} + {% if not page or not page.no_header %} +
+
+

+ {% block header-title %}{% endblock %} +

-
+ + {% endif %} {% endblock %} + {% block content %}{% endblock %} - {% include "kfet/base_footer.html" %} + + {% block footer %} + {% endblock footer %}
+
diff --git a/kfet/templates/kfet/base_col_1.html b/kfet/templates/kfet/base_col_1.html new file mode 100644 index 00000000..03fea5e7 --- /dev/null +++ b/kfet/templates/kfet/base_col_1.html @@ -0,0 +1,16 @@ +{% extends "kfet/base.html" %} + +{% block header-class %}text-center{% endblock %} + +{% block content %} + +
+
+ {% include "kfet/base_messages.html" %} +
+ {% block main %}{% endblock %} +
+
+
+ +{% endblock %} diff --git a/kfet/templates/kfet/base_col_2.html b/kfet/templates/kfet/base_col_2.html new file mode 100644 index 00000000..d0ac45b7 --- /dev/null +++ b/kfet/templates/kfet/base_col_2.html @@ -0,0 +1,19 @@ +{% extends "kfet/base.html" %} + +{% block content %} + +
+
+
+ {% block fixed %}{% endblock %} +
+
+
+ {% include "kfet/base_messages.html" %} +
+ {% block main %}{% endblock %} +
+
+
+ +{% endblock %} diff --git a/kfet/templates/kfet/base_col_mult.html b/kfet/templates/kfet/base_col_mult.html new file mode 100644 index 00000000..e5bafc19 --- /dev/null +++ b/kfet/templates/kfet/base_col_mult.html @@ -0,0 +1,18 @@ +{% extends "kfet/base.html" %} + +{% block header-class %}text-center{% endblock %} + +{% block content %} + +
+
+ {% include "kfet/base_messages.html" %} +
+
+ {% block main %}{% endblock %} +
+
+
+
+ +{% endblock %} diff --git a/kfet/templates/kfet/base_footer.html b/kfet/templates/kfet/base_footer.html index e69de29b..c5333476 100644 --- a/kfet/templates/kfet/base_footer.html +++ b/kfet/templates/kfet/base_footer.html @@ -0,0 +1,17 @@ +{% load wagtailcore_tags %} + +{% with "k-fet@ens.fr" as kfet_mail %} + +
+ +{% endwith %} diff --git a/kfet/templates/kfet/base_form.html b/kfet/templates/kfet/base_form.html new file mode 100644 index 00000000..65fb09d6 --- /dev/null +++ b/kfet/templates/kfet/base_form.html @@ -0,0 +1,9 @@ +{% extends "kfet/base_col_1.html" %} + +{% block main-class %}main-bg main-padding{% endblock %} + +{% block main %} + +{% include "kfet/form_full_snippet.html" %} + +{% endblock %} diff --git a/kfet/templates/kfet/base_messages.html b/kfet/templates/kfet/base_messages.html index 440b8c10..3276bf72 100644 --- a/kfet/templates/kfet/base_messages.html +++ b/kfet/templates/kfet/base_messages.html @@ -1,16 +1,19 @@ +{% if config.gestion_banner %} + +{% endif %} + {% if messages %} -
+
{% for message in messages %} -
-
- - {% if 'safe' in message.tags %} - {{ message|safe }} - {% else %} - {{ message }} - {% endif %} -
-
+
+ + {{ message }} +
{% endfor %}
{% endif %} diff --git a/kfet/templates/kfet/base_nav.html b/kfet/templates/kfet/base_nav.html index b5c98375..f4c07e05 100644 --- a/kfet/templates/kfet/base_nav.html +++ b/kfet/templates/kfet/base_nav.html @@ -1,68 +1,127 @@ -{% load staticfiles %} +{% load i18n static %} +{% load wagtailcore_tags %} + +{% slugurl "kfet" as kfet_home_url %} - - diff --git a/kfet/templates/kfet/category.html b/kfet/templates/kfet/category.html index 5393bf59..0ea96c8f 100644 --- a/kfet/templates/kfet/category.html +++ b/kfet/templates/kfet/category.html @@ -1,53 +1,44 @@ -{% extends 'kfet/base.html' %} +{% extends "kfet/base_col_2.html" %} {% block title %}Categories d'articles{% endblock %} -{% block content-header-title %}Categories d'articles{% endblock %} +{% block header-title %}Categories d'articles{% endblock %} -{% block content %} +{% block fixed %} -
-
-
-
-
{{ categories|length }}
-
catégorie{{ categories|length|pluralize }}
-
-
-
-
- {% include 'kfet/base_messages.html' %} -
-
-

Liste des catégories

-
- - - - - - - - - - - {% for category in categories %} - - - - - - - {% endfor %} - -
NomNombre d'articlesPeut être majorée
- - - - {{ category.name }}{{ category.articles.all|length }}{{ category.has_addcost | yesno:"Oui,Non"}}
-
-
-
+ + +{% endblock %} + +{% block main %} + +
+ + + + + + + + + + {% for category in categories %} + + + + + + {% endfor %} + +
NomNombre d'articlesPeut être majorée
+ + {{ category.name }} + + {{ category.articles.all|length }}{{ category.has_addcost | yesno:"Oui,Non"}}
{% endblock %} diff --git a/kfet/templates/kfet/category_update.html b/kfet/templates/kfet/category_update.html index 1a26d001..8dcead93 100644 --- a/kfet/templates/kfet/category_update.html +++ b/kfet/templates/kfet/category_update.html @@ -1,25 +1,10 @@ -{% extends 'kfet/base.html' %} +{% extends "kfet/base_form.html" %} -{% block title %}Édition de la catégorie {{ category.name }}{% endblock %} -{% block content-header-title %}Catégorie {{ category.name }} - Édition{% endblock %} +{% block title %}{{ articlecategory.name }} - Édition{% endblock %} +{% block header-title %}Édition de la catégorie {{ articlecategory.name }}{% endblock %} -{% block content %} +{% block main %} -{% include "kfet/base_messages.html" %} - -
-
-
-
- {% csrf_token %} - {% include 'kfet/form_snippet.html' with form=form %} - {% if not perms.kfet.edit_articlecategory %} - {% include 'kfet/form_authentication_snippet.html' %} - {% endif %} - {% include 'kfet/form_submit_snippet.html' with value="Enregistrer"%} - -
-
-
+{% include "kfet/form_full_snippet.html" with authz=perms.kfet.edit_articlecategory submit_text="Enregistrer"%} {% endblock %} diff --git a/kfet/templates/kfet/checkout.html b/kfet/templates/kfet/checkout.html index fb2d10a7..96373c49 100644 --- a/kfet/templates/kfet/checkout.html +++ b/kfet/templates/kfet/checkout.html @@ -1,60 +1,55 @@ -{% extends "kfet/base.html" %} +{% extends "kfet/base_col_2.html" %} -{% block title %}Liste des caisses{% endblock %} -{% block content-header-title %}Caisses{% endblock %} +{% block title %}Caisses{% endblock %} +{% block header-title %}Caisses{% endblock %} -{% block content %} +{% block fixed %} -
-
-
-
-
{{ checkouts|length }}
-
caisse{{ checkouts|length|pluralize }}
-
- -
-
-
- {% include 'kfet/base_messages.html' %} -
-
-

Liste des caisses

-
- - - - - - - - - - - - - {% for checkout in checkouts %} - - - - - - - - - {% endfor %} - -
NomBalanceDéb. valid.Fin valid.Protégée
- - - - {{ checkout.name }}{{ checkout.balance}}€{{ checkout.valid_from }}{{ checkout.valid_to }}{{ checkout.is_protected }}
-
-
-
+ + + + +{% endblock %} + +{% block main %} + +
+ + + + + + + + + + + + {% for checkout in checkouts %} + + + + + + + + {% endfor %} + +
NomBalanceDéb. valid.Fin valid.Protégée
+ + {{ checkout.name }} + + {{ checkout.balance}}€{{ checkout.valid_from }}{{ checkout.valid_to }}{{ checkout.is_protected|yesno }}
{% endblock %} diff --git a/kfet/templates/kfet/checkout_create.html b/kfet/templates/kfet/checkout_create.html index 0f254f65..79970572 100644 --- a/kfet/templates/kfet/checkout_create.html +++ b/kfet/templates/kfet/checkout_create.html @@ -1,28 +1,12 @@ -{% extends "kfet/base.html" %} +{% extends "kfet/base_form.html" %} {% block extra_head %}{{ form.media }}{% endblock %} {% block title %}Nouvelle caisse{% endblock %} -{% block content-header-title %}Création d'une caisse{% endblock %} +{% block header-title %}Création d'une caisse{% endblock %} -{% block content %} +{% block main %} -{% include 'kfet/base_messages.html' %} - - {% csrf_token %} - {{ form.non_field_errors}} - {% for field in form %} - {{ field.errors }} - {{ field.label_tag }} -
{{ field }}
- {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} - {% endfor %} - {% if not perms.kfet.add_checkout %} - - {% endif %} - - +{% include "kfet/form_full_snippet.html" with authz=perms.kfet.add_checkout submit_text="Enregistrer" %} - - - - - - - - - + +{{ filter_form.media }} {% endblock %} {% block title %}Historique{% endblock %} -{% block content-header-title %}Historique{% endblock %} +{% block header-title %}Historique{% endblock %} -{% block content %} +{% block fixed %} -
-
-
-
-
-
opérations
-
-

Filtres

-
De
-
à
-
Caisses {{ filter_form.checkouts }}
-
Comptes {{ filter_form.accounts }}
-
-
- -
+ + +{% endblock %} + +{% block main %} + +
- -{% endblock %} {% block title %}Nouvel inventaire{% endblock %} -{% block content-header-title %}Nouvel inventaire{% endblock %} +{% block header-title %}Création d'un inventaire{% endblock %} -{% block content %} +{% block main-size %}col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2{% endblock %} -{% include 'kfet/base_messages.html' %} -
-
-
- - - - - - - - - - - - - - - {% for form in formset %} - {% ifchanged form.category %} - - - - - {% endifchanged %} - - {{ form.article }} - - - - - - - - - - {% endfor %} - -
ArticleQuantité par caisseStock ThéoriqueCaisses en réserveCaisses en arrièreVracStock totalCompte terminé
{{ form.category_name }}
{{ form.name }}{{ form.box_capacity }}{{ form.stock_old }} -
-
- -
-
-
-
-
-
-
{{ form.stock_new | attr:"readonly"| add_class:"form-control" }}
-
-
- {{ formset.management_form }} - {% if not perms.kfet.add_inventory %} -
- {% include "kfet/form_authentication_snippet.html" %} -
- {% endif %} - - {% csrf_token %} -
+{% block main %} + +
+
+ + + + + + + + + + + + + + + {% for form in formset %} + {% ifchanged form.category %} + + + + + {% endifchanged %} + + {{ form.article }} + + + + + + + + + + {% endfor %} + + + + + + + + +
ArticleQuantité par caisseStock théoriqueCaisses en réserveCaisses en arrièreVracStock totalCompte terminé
{{ form.category_name }}
{{ form.name }}{{ form.box_capacity }} + {{ form.stock_old }} + + + + + + + + {{ form.stock_new | attr:"readonly"| add_class:"form-control" }} + +
+ +
+
+ +
+
Totaux
-
- + {{ formset.management_form }} + {% if not perms.kfet.add_inventory %} +
+ {% include "kfet/form_authentication_snippet.html" %} +
+ {% endif %} + + {% csrf_token %} + - - - - - - - - @@ -20,7 +11,8 @@ {% block title %}K-Psul{% endblock %} -{% block content-header %}{% endblock %} +{% block header %}{% endblock %} +{% block footer %}{% endblock %} {% block help %} @@ -118,11 +110,27 @@
-
- - - - +
+
+ +
+
+ +
+
+ +
+
+ +
diff --git a/kfet/templates/kfet/left_account.html b/kfet/templates/kfet/left_account.html index 5607cbc2..18438ff1 100644 --- a/kfet/templates/kfet/left_account.html +++ b/kfet/templates/kfet/left_account.html @@ -1,49 +1,96 @@ {% load kfet_tags %} -
-
{{ account.trigramme }}
-
{{ account.balance|ukf:account.is_cof }} UKF
-
-
{{ account.name }}
- {% if perms.kfet.is_team %} -
{{ account.nickname }}
- {% endif %} -
- {% if account.email %} - {{ account.email }} - {% else %} - Pas d'email ! + +
-
- {% if account.negative.start %} -
En négatif depuis {{ account.negative.start }}
- {% endif %} - {% if account.negative.balance_offset %} -
Solde réel: {{ account.real_balance }} €
- {% endif %} - {% if account.negative.authz_overdraft_amount %} -
Découvert autorisé: {{ account.negative.authz_overdraft_amount }} €
- {% endif %} - {% if account.negative.authz_overdraft_until %} -
Découvert autorisé jusqu'à : {{ account.negative.authz_overdraft_until }}
- {% endif %} + + {% if account.negative %} +
+

Négatif

+
    + {% if account.negative.start %} +
  • Depuis le {{ account.negative.start|date:"d/m/Y à H:i" }}
  • + {% endif %} + {% if account.real_balance != account.balance %} +
  • Solde réel: {{ account.real_balance }} €
  • + {% endif %} +
  • + Plafond : + {{ account.negative.authz_overdraft_amount|default:kfet_config.overdraft_amount }} € + jusqu'au + {{ account.negative.authz_overdraft_until|default:account.negative.until_default|date:"d/m/Y à H:i" }} +
  • +
-
-
- {% if account.user == request.user %} - {% endif %} - - Modifier - - Recharger par CB + + + + + +{% if account.user == request.user %} + +{% endif %} + + diff --git a/kfet/templates/kfet/left_checkout.html b/kfet/templates/kfet/left_checkout.html index e00eede8..fbfdd7dc 100644 --- a/kfet/templates/kfet/left_checkout.html +++ b/kfet/templates/kfet/left_checkout.html @@ -1,13 +1,24 @@ -
-
{{ checkout.name }}
-
{{ checkout.balance|floatformat:2 }} €
-
-
Valide du {{ checkout.valid_from|date:'l j F Y, G:i' }}
-
au {{ checkout.valid_to|date:'l j F Y, G:i' }}
-
Créée par {{ checkout.created_by }}
+
+ +
+
    +
  • Valide du {{ checkout.valid_from|date:'l j F Y, G:i' }}
  • +
  • au {{ checkout.valid_to|date:'l j F Y, G:i' }}
  • +
  • Créée par {{ checkout.created_by }}
  • +
+
+ + diff --git a/kfet/templates/kfet/login_genericteam.html b/kfet/templates/kfet/login_genericteam.html deleted file mode 100644 index f5f8c863..00000000 --- a/kfet/templates/kfet/login_genericteam.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'kfet/base.html' %} - -{% block content %} - -Connexion utilisateur K-Fêt générique réussie - -{% endblock %} diff --git a/kfet/templates/kfet/nav_item.html b/kfet/templates/kfet/nav_item.html new file mode 100644 index 00000000..b5981266 --- /dev/null +++ b/kfet/templates/kfet/nav_item.html @@ -0,0 +1,22 @@ +{% if not href %} + {% url url as href %} +{% endif %} +
  • + {% if href %} + + {% else %} + + {% endif %}{{ text }} + {% if href %} + + {% else %} + + {% endif %} +
  • diff --git a/kfet/templates/kfet/order.html b/kfet/templates/kfet/order.html index d1fb0442..53ef1bfb 100644 --- a/kfet/templates/kfet/order.html +++ b/kfet/templates/kfet/order.html @@ -1,106 +1,97 @@ -{% extends 'kfet/base.html' %} +{% extends "kfet/base_col_2.html" %} {% block title %}Commandes{% endblock %} -{% block content-header-title %}Commandes{% endblock %} +{% block header-title %}Commandes{% endblock %} -{% block content %} +{% block fixed %} -
    -
    -
    -
    -
    {{ orders|length }}
    -
    commande{{ orders|length|pluralize }}
    -
    -
    -
    -
    +
    + + +{% endblock %} + +{% block main %} + +
    +

    Fournisseurs

    +
    + + + + + + + + + + + {% for supplier in suppliers %} + + + + + + + + + {% endfor %} + +
    NomMailTél.AdresseCommentaire
    + + Commander + + + + {{ supplier.name }} + + {{ supplier.email }}{{ supplier.phone }}{{ supplier.address }}{{ supplier.comment }}
    +
    +
    + +
    +

    Liste des commandes

    +
    + + + + + + + + + + + {% for order in orders %} + + + + + + + {% endfor %} + +
    DateFournisseurInventaire
    + {% if not order.inventory %} + + Générer inventaire + + {% endif %} + + + {{ order.at }} + + {{ order.supplier }} + {% if order.inventory %} + + #{{ order.inventory.pk }} + + {% endif %} +
    +
    +
    {% endblock %} diff --git a/kfet/templates/kfet/order_create.html b/kfet/templates/kfet/order_create.html index c2d07f76..d95cafe3 100644 --- a/kfet/templates/kfet/order_create.html +++ b/kfet/templates/kfet/order_create.html @@ -1,71 +1,72 @@ -{% extends 'kfet/base.html' %} +{% extends "kfet/base_col_1.html" %} {% load widget_tweaks %} {% block title %}Nouvelle commande{% endblock %} -{% block content-header-title %}Nouvelle commande {{ supplier.name }}{% endblock %} +{% block header-title %}Création d'une commande {{ supplier.name }}{% endblock %} -{% block content %} +{% block main-size %}col-lg-8 col-lg-offset-2{% endblock %} + +{% block main %} -
    -
    {% csrf_token %} - +
    +
    - - - - - + - - - - - + {% for label in scale.get_labels %} + + {% endfor %} {% for form in formset %} {% ifchanged form.category %} - - - + + {% endifchanged %} {{ form.article }} - - - - - - + + {% for v_chunk in form.v_all %} + + {% endfor %} + - + {% endfor %}
    ArticleVentes + Ventes V. moy. + V. moy.
    E.T. + E.T.
    Prév. + Prév.
    StockRec. + Box
    + +
    Rec.
    Commande
    S1S2S3S4S5{{ label }}
    {{ form.category_name }}
    {{ form.category_name }}
    {{ form.name }}{{ form.v_s1 }}{{ form.v_s2 }}{{ form.v_s3 }}{{ form.v_s4 }}{{ form.v_s5 }}{{ form.name }}{{ v_chunk }}{{ form.v_moy }} {{ form.v_et }} {{ form.v_prev }} {{ form.stock }}{{ form.box_capacity|default:"" }} {{ form.c_rec }}{{ form.quantity_ordered | add_class:"form-control" }}{{ form.quantity_ordered | add_class:"form-control" }}
    +
    {{ formset.management_form }} {% if not perms.kfet.add_inventory %}
    @@ -74,8 +75,6 @@ {% endif %} -
    -
    - - - - - - - {% endblock %} {% block title %}Transferts{% endblock %} -{% block content-header-title %}Transferts{% endblock %} +{% block header-title %}Transferts{% endblock %} -{% block content %} +{% block fixed %} -
    -
    -
    -
    -
    -
    transferts
    -
    - -
    -
    -
    - {% include 'kfet/base_messages.html' %} -
    -
    -

    Liste des transferts

    -
    -
    -
    -
    + + + +{% endblock %} + +{% block main %} + +
    {% endblock %} {% block title %}Nouveaux transferts{% endblock %} -{% block content-header-title %}Nouveaux transferts{% endblock %} +{% block header-title %}Création de transferts{% endblock %} -{% block content %} +{% block main-size %}col-sm-12{% endblock %} -{% csrf_token %} +{% block main %}
    + {% csrf_token %}
    diff --git a/kfet/templatetags/kfet_tags.py b/kfet/templatetags/kfet_tags.py index 7fa9d7c7..f5cd3848 100644 --- a/kfet/templatetags/kfet_tags.py +++ b/kfet/templatetags/kfet_tags.py @@ -1,15 +1,18 @@ # -*- coding: utf-8 -*- +import re + from django import template from django.utils.html import escape from django.utils.safestring import mark_safe -from math import floor -import re -from kfet.config import kfet_config +from ..utils import to_ukf + register = template.Library() +register.filter('ukf', to_ukf) + @register.filter() def highlight_text(text, q): @@ -28,7 +31,6 @@ def highlight_user(user, q): return highlight_text(text, q) - @register.filter(is_safe=True) def highlight_clipper(clipper, q): if clipper.fullname: @@ -38,8 +40,14 @@ def highlight_clipper(clipper, q): return highlight_text(text, q) +@register.filter() +def widget_type(field): + return field.field.widget.__class__.__name__ + @register.filter() -def ukf(balance, is_cof): - grant = is_cof and (1 + kfet_config.subvention_cof / 100) or 1 - return floor(balance * 10 * grant) +def slice(l, start, end=None): + if end is None: + end = start + start = 0 + return l[start:end] diff --git a/kfet/tests/test_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_statistic.py b/kfet/tests/test_statistic.py index 4fb0785d..d8db7ec8 100644 --- a/kfet/tests/test_statistic.py +++ b/kfet/tests/test_statistic.py @@ -9,7 +9,8 @@ from kfet.models import Account, Article, ArticleCategory class TestStats(TestCase): - @patch('kfet.signals.messages') + + @patch('gestioncof.signals.messages') def test_user_stats(self, mock_messages): """ Checks that we can get the stat-related pages without any problem. diff --git a/kfet/tests/test_tests_utils.py b/kfet/tests/test_tests_utils.py new file mode 100644 index 00000000..8308bd5b --- /dev/null +++ b/kfet/tests/test_tests_utils.py @@ -0,0 +1,95 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from gestioncof.models import CofProfile + +from ..models import Account +from .testcases import TestCaseMixin +from .utils import ( + create_user, create_team, create_root, get_perms, user_add_perms, +) + + +User = get_user_model() + + +class UserHelpersTests(TestCaseMixin, TestCase): + + def test_create_user(self): + """create_user creates a basic user and its account.""" + u = create_user() + a = u.profile.account_kfet + + self.assertInstanceExpected(u, { + 'get_full_name': 'first last', + 'username': 'user', + }) + self.assertFalse(u.user_permissions.exists()) + + self.assertEqual('000', a.trigramme) + + def test_create_team(self): + u = create_team() + a = u.profile.account_kfet + + self.assertInstanceExpected(u, { + 'get_full_name': 'team member', + 'username': 'team', + }) + self.assertTrue(u.has_perm('kfet.is_team')) + + self.assertEqual('100', a.trigramme) + + def test_create_root(self): + u = create_root() + a = u.profile.account_kfet + + self.assertInstanceExpected(u, { + 'get_full_name': 'super user', + 'username': 'root', + 'is_superuser': True, + 'is_staff': True, + }) + + self.assertEqual('200', a.trigramme) + + +class PermHelpersTest(TestCaseMixin, TestCase): + + def setUp(self): + cts = ContentType.objects.get_for_models(Account, CofProfile) + self.perm1 = Permission.objects.create( + content_type=cts[Account], + codename='test_perm', + name='Perm for test', + ) + self.perm2 = Permission.objects.create( + content_type=cts[CofProfile], + codename='another_test_perm', + name='Another one', + ) + self.perm_team = Permission.objects.get( + content_type__app_label='kfet', + codename='is_team', + ) + + def test_get_perms(self): + perms = get_perms('kfet.test_perm', 'gestioncof.another_test_perm') + self.assertDictEqual(perms, { + 'kfet.test_perm': self.perm1, + 'gestioncof.another_test_perm': self.perm2, + }) + + def test_user_add_perms(self): + user = User.objects.create_user(username='user', password='user') + user.user_permissions.add(self.perm1) + + user_add_perms(user, ['kfet.is_team', 'gestioncof.another_test_perm']) + + self.assertQuerysetEqual( + user.user_permissions.all(), + map(repr, [self.perm1, self.perm2, self.perm_team]), + ordered=False, + ) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py new file mode 100644 index 00000000..41ed8b5c --- /dev/null +++ b/kfet/tests/test_views.py @@ -0,0 +1,2235 @@ +import json +from datetime import datetime, timedelta +from decimal import Decimal +from unittest import mock + +from django.contrib.auth.models import Group +from django.core.urlresolvers import reverse +from django.test import Client, TestCase +from django.utils import timezone + +from ..config import kfet_config +from ..models import ( + Account, Article, ArticleCategory, Checkout, CheckoutStatement, Inventory, + InventoryArticle, Operation, OperationGroup, Order, OrderArticle, Supplier, + SupplierArticle, Transfer, TransferGroup, +) +from .testcases import ViewTestCaseMixin +from .utils import create_team, create_user, get_perms + + +class AccountListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account' + url_expected = '/k-fet/accounts/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class AccountValidFreeTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.is_validandfree.ajax' + url_expected = '/k-fet/accounts/is_validandfree' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok_isvalid_isfree(self): + """Upper case trigramme not taken is valid and free.""" + r = self.client.get(self.url, {'trigramme': 'AAA'}) + self.assertDictEqual(json.loads(r.content.decode('utf-8')), { + 'is_valid': True, + 'is_free': True, + }) + + def test_ok_isvalid_notfree(self): + """Already taken trigramme is not free, but valid.""" + r = self.client.get(self.url, {'trigramme': '000'}) + self.assertDictEqual(json.loads(r.content.decode('utf-8')), { + 'is_valid': True, + 'is_free': False, + }) + + def test_ok_notvalid_isfree(self): + """Lower case if forbidden but free.""" + r = self.client.get(self.url, {'trigramme': 'aaa'}) + self.assertDictEqual(json.loads(r.content.decode('utf-8')), { + 'is_valid': False, + 'is_free': True, + }) + + +class AccountCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.create' + url_expected = '/k-fet/accounts/new' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + 'trigramme': 'AAA', + 'username': 'plopplopplop', + 'first_name': 'first', + 'last_name': 'last', + 'email': 'email@domain.net', + } + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.add_account']), + } + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.account.create')) + + account = Account.objects.get(trigramme='AAA') + + self.assertInstanceExpected(account, { + 'username': 'plopplopplop', + 'first_name': 'first', + 'last_name': 'last', + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class AccountCreateAjaxViewTests(ViewTestCaseMixin, TestCase): + urls_conf = [{ + 'name': 'kfet.account.create.fromuser', + 'kwargs': {'username': 'user'}, + 'expected': '/k-fet/accounts/new/user/user', + }, { + 'name': 'kfet.account.create.fromclipper', + 'kwargs': { + 'login_clipper': 'myclipper', + 'fullname': 'first last1 last2', + }, + 'expected': ( + '/k-fet/accounts/new/clipper/myclipper/first%20last1%20last2' + ), + }, { + 'name': 'kfet.account.create.empty', + 'expected': '/k-fet/accounts/new/empty', + }] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_fromuser(self): + r = self.client.get(self.t_urls[0]) + self.assertEqual(r.status_code, 200) + + user = self.users['user'] + + self.assertEqual(r.context['user_form'].instance, user) + self.assertEqual(r.context['cof_form'].instance, user.profile) + self.assertIn('account_form', r.context) + + def test_fromclipper(self): + r = self.client.get(self.t_urls[1]) + self.assertEqual(r.status_code, 200) + + self.assertIn('user_form', r.context) + self.assertIn('cof_form', r.context) + self.assertIn('account_form', r.context) + + def test_empty(self): + r = self.client.get(self.t_urls[2]) + self.assertEqual(r.status_code, 200) + + self.assertIn('user_form', r.context) + self.assertIn('cof_form', r.context) + self.assertIn('account_form', r.context) + + +class AccountCreateAutocompleteViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.create.autocomplete' + url_expected = '/k-fet/autocomplete/account_new' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url, {'q': 'first'}) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(r.context['users_notcof']), 0) + self.assertEqual(len(r.context['users_cof']), 0) + self.assertSetEqual(set(r.context['kfet']), set([ + (self.accounts['user'], self.users['user']), + ])) + + +class AccountSearchViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.search.autocomplete' + url_expected = '/k-fet/autocomplete/account_search' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def test_ok(self): + r = self.client.get(self.url, {'q': 'first'}) + self.assertEqual(r.status_code, 200) + self.assertSetEqual(set(r.context['accounts']), set([ + ('000', 'first last'), + ])) + + +class AccountReadViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.read' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def get_users_extra(self): + return { + 'user1': create_user('user1', '001'), + } + + def setUp(self): + super().setUp() + + user1_acc = self.accounts['user1'] + team_acc = self.accounts['team'] + + # Dummy operations and operation groups + checkout = Checkout.objects.create( + created_by=team_acc, name="checkout", + valid_from=timezone.now(), + valid_to=timezone.now() + timezone.timedelta(days=365) + ) + opeg_data = [ + (timezone.now(), Decimal('10')), + (timezone.now() - timezone.timedelta(days=3), Decimal('3')), + ] + OperationGroup.objects.bulk_create([ + OperationGroup( + on_acc=user1_acc, checkout=checkout, at=at, is_cof=False, + amount=amount + ) + for (at, amount) in opeg_data + ]) + self.operation_groups = OperationGroup.objects.order_by("-amount") + Operation.objects.create( + group=self.operation_groups[0], + type=Operation.PURCHASE, + amount=Decimal('10') + ) + Operation.objects.create( + group=self.operation_groups[1], + type=Operation.PURCHASE, + amount=Decimal('3') + ) + + def test_ok(self): + """We can query the "Account - Read" page.""" + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_ok_self(self): + client = Client() + client.login(username='user1', password='user1') + r = client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class AccountUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.update' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/edit' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + # User + 'first_name': 'The first', + 'last_name': 'The last', + 'email': '', + # Group + 'groups[]': [], + # Account + 'trigramme': '051', + 'nickname': '', + 'promo': '', + # 'is_frozen': not checked + # Account password + 'pwd1': '', + 'pwd2': '', + } + + def get_users_extra(self): + return { + 'user1': create_user('user1', '001'), + 'team1': create_team('team1', '101', perms=[ + 'kfet.change_account', + ]), + } + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_get_ok_self(self): + client = Client() + client.login(username='user1', password='user1') + r = client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.account.read', args=['051'])) + + self.accounts['user1'].refresh_from_db() + self.users['user1'].refresh_from_db() + + self.assertInstanceExpected(self.accounts['user1'], { + 'first_name': 'The first', + 'last_name': 'The last', + 'trigramme': '051', + }) + + def test_post_ok_self(self): + client = Client() + client.login(username='user1', password='user1') + + post_data = { + 'first_name': 'The first', + 'last_name': 'The last', + } + + r = client.post(self.url, post_data) + self.assertRedirects(r, reverse('kfet.account.read', args=['001'])) + + self.accounts['user1'].refresh_from_db() + self.users['user1'].refresh_from_db() + + self.assertInstanceExpected(self.accounts['user1'], { + 'first_name': 'The first', + 'last_name': 'The last', + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class AccountGroupListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.group' + url_expected = '/k-fet/accounts/groups' + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.manage_perms']), + } + + def setUp(self): + super().setUp() + self.group1 = Group.objects.create(name='K-Fêt - Group1') + self.group2 = Group.objects.create(name='K-Fêt - Group2') + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + self.assertQuerysetEqual( + r.context['groups'], + map(repr, [self.group1, self.group2]), + ordered=False, + ) + + +class AccountGroupCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.group.create' + url_expected = '/k-fet/accounts/groups/new' + + http_methods = ['GET', 'POST'] + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.manage_perms']), + } + + @property + def post_data(self): + return { + 'name': 'The Group', + 'permissions': [ + str(self.perms['kfet.is_team'].pk), + str(self.perms['kfet.manage_perms'].pk), + ], + } + + def setUp(self): + super().setUp() + self.perms = get_perms( + 'kfet.is_team', + 'kfet.manage_perms', + ) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + r = self.client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.account.group')) + + group = Group.objects.get(name='K-Fêt The Group') + + self.assertQuerysetEqual( + group.permissions.all(), + map(repr, [ + self.perms['kfet.is_team'], + self.perms['kfet.manage_perms'], + ]), + ordered=False, + ) + + +class AccountGroupUpdateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.group.update' + + http_methods = ['GET', 'POST'] + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + @property + def url_kwargs(self): + return {'pk': self.group.pk} + + @property + def url_expected(self): + return '/k-fet/accounts/groups/{}/edit'.format(self.group.pk) + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.manage_perms']), + } + + @property + def post_data(self): + return { + 'name': 'The Group', + 'permissions': [ + str(self.perms['kfet.is_team'].pk), + str(self.perms['kfet.manage_perms'].pk), + ], + } + + def setUp(self): + super().setUp() + self.perms = get_perms( + 'kfet.is_team', + 'kfet.manage_perms', + ) + self.group = Group.objects.create(name='K-Fêt - Group') + self.group.permissions.set(self.perms.values()) + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + r = self.client.post(self.url, self.post_data) + self.assertRedirects(r, reverse('kfet.account.group')) + + self.group.refresh_from_db() + + self.assertEqual(self.group.name, 'K-Fêt The Group') + self.assertQuerysetEqual( + self.group.permissions.all(), + map(repr, [ + self.perms['kfet.is_team'], + self.perms['kfet.manage_perms'], + ]), + ordered=False, + ) + + +class AccountNegativeListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.negative' + url_expected = '/k-fet/accounts/negatives' + + auth_user = 'team1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.view_negs']), + } + + def setUp(self): + super().setUp() + account = self.accounts['user'] + account.balance = -5 + account.save() + account.update_negative() + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['negatives'], + map(repr, [self.accounts['user'].negative]), + ordered=False, + ) + + +class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.stat.operation.list' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/stat/operations/list' + + auth_user = 'user1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return {'user1': create_user('user1', '001')} + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + content = json.loads(r.content.decode('utf-8')) + + base_url = reverse('kfet.account.stat.operation', args=['001']) + + expected_stats = [{ + 'label': 'Derniers mois', + 'url': { + 'path': base_url, + 'query': { + 'scale_n_steps': ['7'], + 'scale_name': ['month'], + 'types': ["['purchase']"], + 'scale_last': ['True'], + }, + }, + }, { + 'label': 'Dernières semaines', + 'url': { + 'path': base_url, + 'query': { + 'scale_n_steps': ['7'], + 'scale_name': ['week'], + 'types': ["['purchase']"], + 'scale_last': ['True'], + }, + }, + }, { + 'label': 'Derniers jours', + 'url': { + 'path': base_url, + 'query': { + 'scale_n_steps': ['7'], + 'scale_name': ['day'], + 'types': ["['purchase']"], + 'scale_last': ['True'], + }, + }, + }] + + for stat, expected in zip(content['stats'], expected_stats): + expected_url = expected.pop('url') + self.assertUrlsEqual(stat['url'], expected_url) + self.assertDictContainsSubset(expected, stat) + + +class AccountStatOperationViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.stat.operation' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/stat/operations' + + auth_user = 'user1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return {'user1': create_user('user1', '001')} + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class AccountStatBalanceListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.stat.balance.list' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/stat/balance/list' + + auth_user = 'user1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return {'user1': create_user('user1', '001')} + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + content = json.loads(r.content.decode('utf-8')) + + base_url = reverse('kfet.account.stat.balance', args=['001']) + + expected_stats = [{ + 'label': 'Tout le temps', + 'url': base_url, + }, { + 'label': '1 an', + 'url': { + 'path': base_url, + 'query': {'last_days': ['365']}, + }, + }, { + 'label': '6 mois', + 'url': { + 'path': base_url, + 'query': {'last_days': ['183']}, + }, + }, { + 'label': '3 mois', + 'url': { + 'path': base_url, + 'query': {'last_days': ['90']}, + }, + }, { + 'label': '30 jours', + 'url': { + 'path': base_url, + 'query': {'last_days': ['30']}, + }, + }] + + for stat, expected in zip(content['stats'], expected_stats): + expected_url = expected.pop('url') + self.assertUrlsEqual(stat['url'], expected_url) + self.assertDictContainsSubset(expected, stat) + + +class AccountStatBalanceViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.account.stat.balance' + url_kwargs = {'trigramme': '001'} + url_expected = '/k-fet/accounts/001/stat/balance' + + auth_user = 'user1' + auth_forbidden = [None, 'user', 'team'] + + def get_users_extra(self): + return {'user1': create_user('user1', '001')} + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + +class CheckoutListViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkout' + url_expected = '/k-fet/checkouts/' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + def setUp(self): + super().setUp() + self.checkout1 = Checkout.objects.create( + name='Checkout 1', + created_by=self.accounts['team'], + valid_from=self.now, + valid_to=self.now + timedelta(days=5), + ) + self.checkout2 = Checkout.objects.create( + name='Checkout 2', + created_by=self.accounts['team'], + valid_from=self.now + timedelta(days=10), + valid_to=self.now + timedelta(days=15), + ) + + def test_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['checkouts'], + map(repr, [self.checkout1, self.checkout2]), + ordered=False, + ) + + +class CheckoutCreateViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkout.create' + url_expected = '/k-fet/checkouts/new' + + http_methods = ['GET', 'POST'] + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + post_data = { + 'name': 'Checkout', + 'valid_from': '2017-10-08 17:45:00', + 'valid_to': '2017-11-08 16:00:00', + 'balance': '3.14', + # 'is_protected': not checked + } + + def get_users_extra(self): + return { + 'team1': create_team('team1', '101', perms=['kfet.add_checkout']), + } + + def test_get_ok(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_ok(self): + client = Client() + client.login(username='team1', password='team1') + + r = client.post(self.url, self.post_data) + + checkout = Checkout.objects.get(name='Checkout') + self.assertRedirects(r, checkout.get_absolute_url()) + + self.assertInstanceExpected(checkout, { + 'name': 'Checkout', + 'valid_from': timezone.make_aware(datetime(2017, 10, 8, 17, 45)), + 'valid_to': timezone.make_aware(datetime(2017, 11, 8, 16, 00)), + 'balance': Decimal('3.14'), + 'is_protected': False, + }) + + def test_post_forbidden(self): + r = self.client.post(self.url, self.post_data) + self.assertForbiddenKfet(r) + + +class CheckoutReadViewTests(ViewTestCaseMixin, TestCase): + url_name = 'kfet.checkout.read' + + auth_user = 'team' + auth_forbidden = [None, 'user'] + + @property + def url_kwargs(self): + return {'pk': self.checkout.pk} + + @property + def url_expected(self): + return '/k-fet/checkouts/{}'.format(self.checkout.pk) + + def setUp(self): + super().setUp() + 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 b3bd1f48..b5a9ad9b 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -1,16 +1,15 @@ # -*- coding: utf-8 -*- -from django.conf.urls import url +from django.conf.urls import include, url from django.contrib.auth.decorators import permission_required -from kfet import views -from kfet import autocomplete + +from kfet import autocomplete, views from kfet.decorators import teamkfet_required + urlpatterns = [ - url(r'^$', views.Home.as_view(), - name='kfet.home'), - url(r'^login/genericteam$', views.login_genericteam, - name='kfet.login.genericteam'), + url(r'^login/generic$', views.login_generic, + name='kfet.login.generic'), url(r'^history$', views.history, name='kfet.history'), @@ -184,15 +183,12 @@ urlpatterns = [ # Settings urls # ----- - url(r'^settings/$', - permission_required('kfet.change_settings') - (views.SettingsList.as_view()), + url(r'^settings/$', views.config_list, name='kfet.settings'), - url(r'^settings/edit$', - permission_required('kfet.change_settings') - (views.SettingsUpdate.as_view()), + url(r'^settings/edit$', views.config_update, name='kfet.settings.update'), + # ----- # Transfers urls # ----- @@ -235,3 +231,8 @@ urlpatterns = [ url(r'^orders/(?P\d+)/to_inventory$', views.order_to_inventory, name='kfet.order.to_inventory'), ] + +urlpatterns += [ + # K-Fêt Open urls + url('^open/', include('kfet.open.urls')), +] diff --git a/kfet/utils.py b/kfet/utils.py new file mode 100644 index 00000000..3d06bb0b --- /dev/null +++ b/kfet/utils.py @@ -0,0 +1,115 @@ +import math +import json + +from django.core.cache import cache +from django.core.serializers.json import DjangoJSONEncoder + +from channels.channel import Group +from channels.generic.websockets import JsonWebsocketConsumer + +from .config import kfet_config + + +def to_ukf(balance, is_cof=False): + """Convert euro to UKF.""" + subvention = kfet_config.subvention_cof + grant = (1 + subvention / 100) if is_cof else 1 + return math.floor(balance * 10 * grant) + +# Storage + +class CachedMixin: + """Object with cached properties. + + Attributes: + cached (dict): Keys are cached properties. Associated value is the + returned default by getters in case the key is missing from cache. + cache_prefix (str): Used to prefix keys in cache. + + """ + cached = {} + cache_prefix = '' + + def __init__(self, cache_prefix=None, *args, **kwargs): + super().__init__(*args, **kwargs) + if cache_prefix is not None: + self.cache_prefix = cache_prefix + + def cachekey(self, attr): + return '{}__{}'.format(self.cache_prefix, attr) + + def __getattr__(self, attr): + if attr in self.cached: + return cache.get(self.cachekey(attr), self.cached.get(attr)) + elif hasattr(super(), '__getattr__'): + return super().__getattr__(attr) + else: + raise AttributeError("can't get attribute") + + def __setattr__(self, attr, value): + if attr in self.cached: + cache.set(self.cachekey(attr), value) + elif hasattr(super(), '__setattr__'): + super().__setattr__(attr, value) + else: + raise AttributeError("can't set attribute") + + def clear_cache(self): + cache.delete_many([ + self.cachekey(attr) for attr in self.cached.keys() + ]) + + +# Consumers + +class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer): + """Custom Json Websocket Consumer. + + Encode to JSON with DjangoJSONEncoder. + + """ + + @classmethod + def encode_json(cls, content): + return json.dumps(content, cls=DjangoJSONEncoder) + + +class PermConsumerMixin: + """Add support to check permissions on consumers. + + Attributes: + perms_connect (list): Required permissions to connect to this + consumer. + + message.user is appended as argument to each connection_groups method call. + + """ + http_user = True # Enable message.user + perms_connect = [] + + def connect(self, message, **kwargs): + """Check permissions on connection.""" + if message.user.has_perms(self.perms_connect): + super().connect(message, **kwargs) + else: + self.close() + + def raw_connect(self, message, **kwargs): + # Same as original raw_connect method of JsonWebsocketConsumer + # We add user to connection_groups call. + groups = self.connection_groups(user=message.user, **kwargs) + for group in groups: + Group(group, channel_layer=message.channel_layer).add(message.reply_channel) + self.connect(message, **kwargs) + + def raw_disconnect(self, message, **kwargs): + # Same as original raw_connect method of JsonWebsocketConsumer + # We add user to connection_groups call. + groups = self.connection_groups(user=message.user, **kwargs) + for group in groups: + Group(group, channel_layer=message.channel_layer).discard(message.reply_channel) + self.disconnect(message, **kwargs) + + def connection_groups(self, user, **kwargs): + """`message.user` is available as `user` arg. Original behavior.""" + return super().connection_groups(user=user, **kwargs) diff --git a/kfet/views.py b/kfet/views.py index 1f4495eb..2280bc48 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -12,31 +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 Q, F, Sum, Prefetch, Count from django.db.models.functions import Coalesce from django.utils import timezone -from django.utils.crypto import get_random_string from django.utils.decorators import method_decorator + from gestioncof.models import CofProfile from kfet.config import kfet_config from kfet.decorators import teamkfet_required from kfet.models import ( Account, Checkout, Article, AccountNegative, - CheckoutStatement, GenericTeamToken, Supplier, SupplierArticle, Inventory, + CheckoutStatement, Supplier, SupplierArticle, Inventory, InventoryArticle, Order, OrderArticle, Operation, OperationGroup, TransferGroup, Transfer, ArticleCategory) from kfet.forms import ( AccountTriForm, AccountBalanceForm, AccountNoTriForm, UserForm, CofForm, UserRestrictTeamForm, UserGroupForm, AccountForm, CofRestrictForm, AccountPwdForm, AccountNegativeForm, UserRestrictForm, AccountRestrictForm, - GroupForm, CheckoutForm, CheckoutRestrictForm, CheckoutStatementCreateForm, + CheckoutForm, CheckoutRestrictForm, CheckoutStatementCreateForm, CheckoutStatementUpdateForm, ArticleForm, ArticleRestrictForm, KPsulOperationGroupForm, KPsulAccountForm, KPsulCheckoutForm, KPsulOperationFormSet, AddcostForm, FilterHistoryForm, @@ -47,11 +46,12 @@ from collections import defaultdict from kfet import consumers from datetime import timedelta from decimal import Decimal -import django_cas_ng import heapq import statistics from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes, WeekScale - +from .auth.views import ( # noqa + account_group, login_generic, AccountGroupCreate, AccountGroupUpdate, +) # source : docs.djangoproject.com/fr/1.10/topics/class-based-views/mixins/ @@ -79,54 +79,6 @@ class JSONResponseMixin(object): return context -class Home(TemplateView): - template_name = "kfet/home.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - articles = list( - Article.objects - .filter(is_sold=True, hidden=False) - .select_related('category') - .order_by('category__name') - ) - pressions, others = [], [] - while len(articles) > 0: - article = articles.pop() - if article.category.name == 'Pression': - pressions.append(article) - else: - others.append(article) - context['pressions'], context['articles'] = pressions, others - return context - - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super(TemplateView, self).dispatch(*args, **kwargs) - - -@teamkfet_required -def login_genericteam(request): - # Check si besoin de déconnecter l'utilisateur de CAS - profile, _ = CofProfile.objects.get_or_create(user=request.user) - need_cas_logout = False - if profile.login_clipper: - need_cas_logout = True - # Récupèration de la vue de déconnexion de CAS - # Ici, car request sera modifié après - logout_cas = django_cas_ng.views.logout(request) - - # Authentification du compte générique - token = GenericTeamToken.objects.create(token=get_random_string(50)) - user = authenticate(username="kfet_genericteam", token=token.token) - login(request, user) - - if need_cas_logout: - # Vue de déconnexion de CAS - return logout_cas - - return render(request, "kfet/login_genericteam.html") - def put_cleaned_data_in_dict(dict, form): for field in form.cleaned_data: dict[field] = form.cleaned_data[field] @@ -320,10 +272,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 @@ -392,8 +344,9 @@ def account_read(request, trigramme): account = get_object_or_404(Account, trigramme=trigramme) # Checking permissions - if not request.user.has_perm('kfet.is_team') \ - and request.user != account.user: + if not account.readable or ( + not request.user.has_perm('kfet.is_team') and + request.user != account.user): raise PermissionDenied if request.GET.get('format') == 'json': @@ -564,37 +517,6 @@ def account_update(request, trigramme): }) -@permission_required('kfet.manage_perms') -def account_group(request): - user_pre = Prefetch( - 'user_set', - queryset=User.objects.select_related('profile__account_kfet'), - ) - groups = ( - Group.objects - .filter(name__icontains='K-Fêt') - .prefetch_related('permissions', user_pre) - ) - return render(request, 'kfet/account_group.html', { - 'groups': groups, - }) - - -class AccountGroupCreate(SuccessMessageMixin, CreateView): - model = Group - template_name = 'kfet/account_group_form.html' - form_class = GroupForm - success_message = 'Nouveau groupe : %(name)s' - success_url = reverse_lazy('kfet.account.group') - -class AccountGroupUpdate(UpdateView): - queryset = Group.objects.filter(name__icontains='K-Fêt') - template_name = 'kfet/account_group_form.html' - form_class = GroupForm - success_message = 'Groupe modifié : %(name)s' - success_url = reverse_lazy('kfet.account.group') - - class AccountNegativeList(ListView): queryset = ( AccountNegative.objects @@ -1504,8 +1426,7 @@ def history_json(request): # Construction de la requête (sur les opérations) pour le prefetch ope_queryset_prefetch = Operation.objects.select_related( - 'canceled_by', 'addcost_for', - 'article') + 'canceled_by', 'addcost_for', 'article') ope_prefetch = Prefetch('opes', queryset=ope_queryset_prefetch) @@ -1530,15 +1451,15 @@ def history_json(request): opegroups = ( OperationGroup.objects .prefetch_related(ope_prefetch) - .select_related('on_acc__trigramme', - 'valid_by__trigramme') + .select_related('on_acc', + 'valid_by') .order_by('at') ) transfergroups = ( TransferGroup.objects .prefetch_related(transfer_prefetch) - .select_related('valid_by__trigramme') + .select_related('valid_by') .order_by('at') ) @@ -1698,6 +1619,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' @@ -1706,13 +1630,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 @@ -2053,9 +1981,12 @@ def order_create(request, pk): else: formset = cls_formset(initial=initial) + scale.label_fmt = "S -{rev_i}" + return render(request, 'kfet/order_create.html', { 'supplier': supplier, 'formset': formset, + 'scale': scale, }) @@ -2202,6 +2133,7 @@ def order_to_inventory(request, pk): return render(request, 'kfet/order_to_inventory.html', { 'formset': formset, + 'order': order, }) class SupplierUpdate(SuccessMessageMixin, UpdateView): @@ -2440,7 +2372,7 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView): ) context['charts'] = [{ - "color": "rgb(255, 99, 132)", + "color": "rgb(200, 20, 60)", "label": "Balance", "values": changes, }] @@ -2535,7 +2467,7 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): ventes = sum(ope['article_nb'] for ope in chunk) nb_ventes.append(ventes) - context['charts'] = [{"color": "rgb(255, 99, 132)", + context['charts'] = [{"color": "rgb(200, 20, 60)", "label": "NB items achetés", "values": nb_ventes}] return context @@ -2617,7 +2549,7 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): nb_accounts.append(sum_accounts) nb_liq.append(sum_liq) - context['charts'] = [{"color": "rgb(255, 99, 132)", + context['charts'] = [{"color": "rgb(200, 20, 60)", "label": "Toutes consommations", "values": nb_ventes}, {"color": "rgb(54, 162, 235)", diff --git a/manage.py b/manage.py old mode 100644 new mode 100755 index 7f4e79f6..094ec16f --- a/manage.py +++ b/manage.py @@ -3,7 +3,7 @@ import os import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cof.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cof.settings.local") from django.core.management import execute_from_command_line diff --git a/provisioning/apache.conf b/provisioning/apache.conf deleted file mode 100644 index db5bd602..00000000 --- a/provisioning/apache.conf +++ /dev/null @@ -1,39 +0,0 @@ - - ServerName default - DocumentRoot /var/www/html - - ProxyPreserveHost On - ProxyRequests Off - ProxyPass /static/ ! - ProxyPass /media/ ! - # Pour utiliser un sous-dossier (typiquement /gestion/), il faut faire a la - # place des lignes suivantes: - # - # RequestHeader set Daphne-Root-Path /gestion - # ProxyPass /gestion/ws/ ws://127.0.0.1:8001/ws/ - # ProxyPass /gestion http://127.0.0.1:8001/gestion - # ProxyPassReverse /gestion http://127.0.0.1:8001/gestion - # - # Penser egalement a changer les /static/ et /media/ dans la config apache - # ainsi que dans les settings django. - ProxyPass /ws/ ws://127.0.0.1:8001/ws/ - ProxyPass / http://127.0.0.1:8001/ - ProxyPassReverse / http://127.0.0.1:8001/ - - Alias /media /vagrant/media - Alias /static /var/www/static - - Order deny,allow - Allow from all - - - Order deny,allow - Allow from all - - - ErrorLog ${APACHE_LOG_DIR}/error.log - CustomLog ${APACHE_LOG_DIR}/access.log combined - - - -# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index 38efdfb5..69bbcf4c 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -8,76 +8,64 @@ DBNAME="cof_gestion" DBPASSWD="4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4" # Installation de paquets utiles -apt-get update && apt-get install -y python3-pip python3-dev python3-venv \ - libmysqlclient-dev libjpeg-dev git redis-server -pip install -U pip +apt-get update && apt-get upgrade -y +apt-get install -y python3-pip python3-dev python3-venv libpq-dev postgresql \ + postgresql-contrib libjpeg-dev nginx git redis-server -# Configuration et installation de mysql. Le mot de passe root est le même que -# le mot de passe pour l'utilisateur local - pour rappel, ceci est une instance -# locale de développement. -echo "mysql-server mysql-server/root_password password $DBPASSWD" | debconf-set-selections -echo "mysql-server mysql-server/root_password_again password $DBPASSWD" | debconf-set-selections +# Postgresql +sudo -u postgres createdb $DBNAME +sudo -u postgres createuser -SdR $DBUSER +sudo -u postgres psql -c "ALTER USER $DBUSER WITH PASSWORD '$DBPASSWD';" +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DBNAME TO $DBUSER;" -apt-get install -y mysql-server -mysql -uroot -p$DBPASSWD -e "CREATE DATABASE $DBNAME; GRANT ALL PRIVILEGES ON $DBNAME.* TO '$DBUSER'@'localhost' IDENTIFIED BY '$DBPASSWD'" -mysql -uroot -p$DBPASSWD -e "GRANT ALL PRIVILEGES ON test_$DBNAME.* TO '$DBUSER'@'localhost'" - -# Configuration de redis +# Redis REDIS_PASSWD="dummy" redis-cli CONFIG SET requirepass $REDIS_PASSWD redis-cli -a $REDIS_PASSWD CONFIG REWRITE -# Installation et configuration d'Apache -apt-get install -y apache2 -a2enmod proxy proxy_http proxy_wstunnel headers -cp /vagrant/provisioning/apache.conf /etc/apache2/sites-available/gestiocof.conf -a2ensite gestiocof -a2dissite 000-default -service apache2 restart -mkdir /var/www/static -chown -R ubuntu:www-data /var/www/static +# Contenu statique +mkdir -p /srv/gestiocof/media +mkdir -p /srv/gestiocof/static +chown -R ubuntu:www-data /srv/gestiocof + +# Nginx +ln -s -f /vagrant/provisioning/nginx.conf /etc/nginx/sites-enabled/gestiocof.conf +rm -f /etc/nginx/sites-enabled/default +systemctl reload nginx + +# Environnement virtuel python +sudo -H -u ubuntu python3 -m venv ~ubuntu/venv +sudo -H -u ubuntu ~ubuntu/venv/bin/pip install -U pip +sudo -H -u ubuntu ~ubuntu/venv/bin/pip install -r /vagrant/requirements-devel.txt + +# Préparation de Django +cd /vagrant +ln -s -f secret_example.py cof/settings/secret.py +sudo -H -u ubuntu \ + DJANGO_SETTINGS_MODULE='cof.settings.dev' \ + bash -c ". ~/venv/bin/activate && bash provisioning/prepare_django.sh" +/home/ubuntu/venv/bin/python manage.py collectstatic --noinput --settings cof.settings.dev + +# Installation du cron pour les mails de rappels +sudo -H -u ubuntu crontab provisioning/cron.dev + +# Daphne + runworker +cp /vagrant/provisioning/daphne.service /etc/systemd/system/daphne.service +cp /vagrant/provisioning/worker.service /etc/systemd/system/worker.service +systemctl enable daphne.service +systemctl enable worker.service +systemctl start daphne.service +systemctl start worker.service # Mise en place du .bash_profile pour tout configurer lors du `vagrant ssh` cat >> ~ubuntu/.bashrc <> /vagrant/rappels.log ; python /vagrant/manage.py sendrappels >> /vagrant/rappels.log 2>&1 -*/5 * * * * python /vagrant/manage.py manage_revente >> /vagrant/reventes.log 2>&1 +19 */12 * * * date >> /vagrant/rappels.log ; /ubuntu/home/venv/bin/python /vagrant/manage.py sendrappels >> /vagrant/rappels.log 2>&1 +*/5 * * * * /ubuntu/home/venv/bin/python /vagrant/manage.py manage_revente >> /vagrant/reventes.log 2>&1 diff --git a/provisioning/cron.md b/provisioning/cron.md index 840a8716..7aff775b 100644 --- a/provisioning/cron.md +++ b/provisioning/cron.md @@ -9,9 +9,9 @@ envoie les mails de rappels des spectacles à venir (sauf s'ils ont déjà été envoyés). - Un fois toutes les 12 heures me semble bien. -- Penser à utiliser le bon executable python (virtualenvs) et les bonnes - variables d'environnement si besoin. -- Garde les logs peut être une bonne idée. +- Penser à utiliser le bon executable python (virtualenvs) et le bon fichier de + settings pour Django. +- Garder les logs peut être une bonne idée. Exemple : voir le fichier `provisioning/cron.dev`. diff --git a/provisioning/daphne.service b/provisioning/daphne.service new file mode 100644 index 00000000..41327ce5 --- /dev/null +++ b/provisioning/daphne.service @@ -0,0 +1,16 @@ +Description="GestioCOF" +After=syslog.target +After=network.target + +[Service] +Type=simple +User=ubuntu +Group=ubuntu +TimeoutSec=300 +WorkingDirectory=/vagrant +Environment="DJANGO_SETTINGS_MODULE=cof.settings.dev" +ExecStart=/home/ubuntu/venv/bin/daphne -u /srv/gestiocof/gestiocof.sock \ + cof.asgi:channel_layer + +[Install] +WantedBy=multi-user.target diff --git a/provisioning/nginx.conf b/provisioning/nginx.conf new file mode 100644 index 00000000..015e1712 --- /dev/null +++ b/provisioning/nginx.conf @@ -0,0 +1,56 @@ +upstream gestiocof { + # Daphne listens on a unix socket + server unix:/srv/gestiocof/gestiocof.sock; +} + +server { + listen 80; + + server_name localhost; + root /srv/gestiocof/; + + # / → /gestion/ + # /gestion → /gestion/ + rewrite ^/$ /gestion/; + rewrite ^/gestion$ /gestion/; + + # Static files + location /static/ { + access_log off; + add_header Cache-Control "public"; + expires 7d; + } + + # Uploaded media + location /media/ { + access_log off; + add_header Cache-Control "public"; + expires 7d; + } + + location /gestion/ { + # A copy-paste of what we have in production + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-SSL-Client-Serial $ssl_client_serial; + proxy_set_header X-SSL-Client-Verify $ssl_client_verify; + proxy_set_header X-SSL-Client-S-DN $ssl_client_s_dn; + proxy_set_header Daphne-Root-Path /gestion; + + location /gestion/ws/ { + # See http://nginx.org/en/docs/http/websocket.html + proxy_buffering off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_pass http://gestiocof/ws/; + } + + location /gestion/ { + proxy_pass http://gestiocof; + } + } +} diff --git a/provisioning/prepare_django.sh b/provisioning/prepare_django.sh index 4ec1a70f..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 a0adbe1e..6e2e2d47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,30 @@ configparser==3.5.0 -Django==1.8.* -django-autocomplete-light==2.3.3 +Django==1.11.* +django-autocomplete-light==3.1.3 django-autoslug==1.9.3 -django-cas-ng==3.5.5 +django-cas-ng==3.5.7 django-djconfig==0.5.3 -django-grappelli==2.8.1 -django-recaptcha==1.0.5 -mysqlclient==1.3.7 -Pillow==3.3.0 -six==1.10.0 -unicodecsv==0.14.1 -icalendar==3.10 +django-recaptcha==1.2.1 +django-redis-cache==1.7.1 +icalendar +psycopg2 +Pillow +six +unicodecsv django-bootstrap-form==3.2.1 asgiref==1.1.1 -daphne==1.2.0 +daphne==1.3.0 asgi-redis==1.3.0 statistics==1.0.3.5 future==0.15.2 django-widget-tweaks==1.4.1 git+https://git.eleves.ens.fr/cof-geek/django_custommail.git#egg=django_custommail ldap3 -channels==1.1.3 django-js-reverse==0.7.3 +channels==1.1.5 python-dateutil +wagtail==1.10.* +wagtailmenus==2.2.* + +# 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())